15 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
119 changed files with 2078 additions and 448 deletions

No files matched your search

+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.
+14 -13
View File
@@ -1,8 +1,8 @@
# Development
Jellyframe uses Godot 4.7.2 and OpenXR. Install Godot and its matching export templates before building. Run the commands below from the project root, with `godot` on your `PATH`.
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`.
Build the native video plugin for your platform before importing the project. Close Jellyframe before rebuilding the plugin.
Build the native video plugin for your platform before importing the project. Close jellyXR before rebuilding the plugin.
## Build the Quest APK
@@ -32,7 +32,7 @@ On Windows, use PowerShell:
-AndroidSdk 'C:/tools/android-sdk' -JavaHome 'C:/tools/jdk-17'
```
The scripts build the Android plugin and export `dist/jellyframe-android.apk`. They download and verify Godot OpenXR Vendors for Quest passthrough support.
The scripts build the Android plugin and export `dist/jellyxr-android.apk`. They download and verify Godot OpenXR Vendors for Quest passthrough support.
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.
@@ -44,10 +44,10 @@ Install Python 3.12, then build the PC plugin and export the app:
./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 script downloads pinned build tools and libraries into `build/desktop`, verifies their checksums, and generates `addons/jellyframe_mpv`. You do not need a system-wide compiler installation.
The 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 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).
@@ -61,20 +61,20 @@ bash tools/build-pc-video.sh
godot --headless --xr-mode off --path . --editor --import --quit
```
The script detects x64 or ARM64 and generates `addons/jellyframe_mpv`. It downloads and verifies godot-cpp, and uses the system libmpv and FFmpeg development packages. FFmpeg 6 or newer is required.
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.
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
```
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
```
Keep the executable and native decoder `.so` file together.
@@ -85,12 +85,12 @@ Install `patchelf`. Copy the licences into the export folder as shown below for
```sh
cp -r build/linux/x86_64/licenses dist/linux-x86_64/
cp LICENSE dist/linux-x86_64/licenses/jellyframe.txt
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
```
The packager bundles decoder libraries and licence notices. It needs network access to download appimagetool and its AppImage runtime. The output is `dist/jellyframe-linux-<arch>.AppImage`. The host supplies graphics drivers and OpenXR.
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.
## Run the app during development
@@ -122,6 +122,7 @@ 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
```
@@ -195,11 +196,11 @@ To record a Vulkan playback log, run the exported app with SteamVR active:
```powershell
New-Item -ItemType Directory -Force logs | Out-Null
$env:JELLYFRAME_VIDEO_STATS = '1'
.\dist\windows\jellyframe.exe --rendering-method mobile --rendering-driver vulkan --log-file (Join-Path $PWD 'logs/vulkan-playback.log')
$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 `JELLYFRAME_PC_RENDERER=software` to force the software fallback for troubleshooting. Remove access tokens and private server details before sharing logs.
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
+17 -17
View File
@@ -1,32 +1,32 @@
# Install and start Jellyframe
# Install and start jellyXR
Download the package for your platform from **Assets** on the [latest release page](https://github.com/Cuteminded/jellyframe/releases/latest).
You need a Jellyfin server and an account to use the app. To build Jellyframe yourself, follow the [build instructions](DEVELOPMENT.md).
You need a Jellyfin server and an account to use the app. To build jellyXR yourself, follow the [build instructions](DEVELOPMENT.md).
## Quest 3
Enable developer mode and USB debugging on your headset. Install Android's `adb` tool on your computer and download `jellyframe-android.apk`.
Enable developer mode and USB debugging on your headset. Install Android's `adb` tool on your computer and download `jellyxr-android.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
```
Open Jellyframe from **Unknown Sources** in your headset.
Open jellyXR from **Unknown Sources** in your headset.
## Windows with SteamVR
Download the Windows ZIP and extract the entire archive. Keep all files together in the extracted folder.
Install SteamVR and connect your headset. In SteamVR's **Settings > OpenXR**, set SteamVR as the active OpenXR runtime. Start SteamVR, then run `jellyframe.exe`.
Install SteamVR and connect your headset. In SteamVR's **Settings > OpenXR**, set SteamVR as the active OpenXR runtime. Start SteamVR, then run `jellyxr.exe`.
If Jellyframe cannot start with Vulkan, try OpenGL from PowerShell in the extracted folder:
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
```
## Linux x64 or ARM64
@@ -36,31 +36,31 @@ Download the AppImage for your computer's architecture, `x86_64` or `arm64`. The
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
```
For ARM64, replace the filename with `jellyframe-linux-arm64.AppImage`. On SteamOS, add the AppImage to Steam as a non-Steam game.
For ARM64, replace the filename with `jellyxr-linux-arm64.AppImage`. On SteamOS, add the AppImage to Steam as a non-Steam game.
If Jellyframe cannot start with Vulkan, try OpenGL:
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`.
### Use system libraries instead of an AppImage
Download the `system-libs` archive for your architecture. On Ubuntu x64, install libmpv, extract the archive, and launch Jellyframe:
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`.
## After installation
+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
+21 -38
View File
@@ -1,60 +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.
> I am still on the reservation queue for Steam Frame and have not received the device yet.
## Hardware status
| 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 | |
> **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!
>
> When I do, I will update the documentation and testing status accordingly.
> 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.
## From JellyQuest to Jellyframe
This project started as JellyQuest, a Jellyfin VR app for the Quest 3. I renamed it Jellyframe because I plan to switch to Steam Frame.
## From JellyQuest to jellyXR
I'm still undecided about keeping the name. It ties the app to Steam Frame, even though I want to support other VR platforms too.
I haven't come up with a better name yet, so Jellyframe stays for now.
Suggestions for a name that isn't tied to a specific platform are welcome.
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.
I want Jellyframe to work on as many VR platforms as possible, including:
- [SteamVR](https://store.steampowered.com/steamvr)
- [Android XR](https://www.android.com/intl/en_us/xr/)
- [Quest headsets running Horizon OS](https://www.meta.com/quest/)
Steam Frame remains the primary development target, with support for other platforms wherever possible.
## What has been tested
- Browsing and searching Jellyfin libraries.
- Selecting titles and controlling playback with controllers.
- Dark backgrounds, cinema environments, and passthrough on supported devices.
- 2D, side-by-side and top-bottom 3D, 180° and 360° video.
- Automatic episode playback in series order.
- HDR, 1080p, 4K, and 8K video playback.
## Current limitations
- Steam Frame installation and playback remain untested.
- 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.
- 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.
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.
## Will it be available in a store?
If we think Jellyframe is ready for a wider release, then yes. I'd be happy to put in the work to make it available on Steam or the Meta Store.
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.
+13 -11
View File
@@ -1,6 +1,6 @@
# Jellyfin feature checklist
This checklist compares Jellyframe with Jellyfin's user-facing features.
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.
@@ -31,7 +31,7 @@ Implementation does not mean a feature has been tested on every headset.
- [x] Browse titles in random order.
- [x] Browse series by season and episode.
- [x] Display watched indicators and saved playback progress supplied by Jellyfin.
- [ ] Show a Next up row for series. Automatic playback of the next episode already exists.
- [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.
@@ -47,8 +47,10 @@ Implementation does not mean a feature has been tested on every headset.
- [x] Resume a movie or episode from its saved position.
- [x] Start a partially watched title from the beginning.
- [x] Report playback start, progress, pause state, and stop events to Jellyfin.
- [ ] Manually mark a movie, episode, season, or series as watched or unwatched.
- [ ] Select between multiple media versions. Playback currently uses the first returned media source.
- [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
@@ -63,10 +65,10 @@ Implementation does not mean a feature has been tested on every headset.
- [x] Browse the library while the current video continues playing.
- [x] Play the next available episode automatically after a cancellable countdown.
- [x] Manually skip intros, credits, recaps, and previews when the server supplies media segments. Requires the Jellyfin 10.10+ API and a segment provider such as Intro Skipper.
- [ ] Enable Direct Stream/remuxing. Playback requests currently set `EnableDirectStream` to `false`.
- [ ] Select audio tracks and languages. The audio/subtitle control is disabled.
- [ ] Display and select embedded or external subtitles. The playback profile currently advertises no subtitle support.
- [ ] Adjust subtitle appearance and timing.
- [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.
@@ -75,7 +77,7 @@ Implementation does not mean a feature has been tested on every headset.
## Other Jellyfin features
These extend beyond Jellyframe's current movie and series focus.
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.
@@ -85,9 +87,9 @@ These extend beyond Jellyframe's current movie and series focus.
- [ ] Join or host SyncPlay sessions.
- [ ] Receive remote playback commands from other Jellyfin clients.
## Jellyframe VR features
## jellyXR VR features
These are Jellyframe features rather than Jellyfin feature gaps.
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.
+60 -21
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
@@ -33,6 +33,8 @@ To reset the saved app settings as well, delete `settings.cfg` from the same fol
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
@@ -53,8 +55,9 @@ Press **Y** on the left controller to open or close settings.
| Forward 30 seconds | Skip ahead. |
| Time bar | Choose a different point in the video. |
| Settings | Change the screen, background, or playback quality. |
| Audio and subtitles | Placeholder for future audio-track and subtitle selection. This button is currently disabled. |
| 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. |
Press **B** to browse your library while playback continues. Select the title that is already playing to return to the video.
@@ -62,9 +65,44 @@ When an episode finishes, a five-second countdown appears before the next availa
### Skip intros and credits
With [Intro Skipper](https://github.com/intro-skipper/intro-skipper) installed on Jellyfin 10.10 or newer, a **Skip intro** or **Skip credits** popup appears at the bottom-right of the video during a detected segment. Point at it and press the trigger to skip, even with the playback controls hidden. When the controls are open, the popup sits just above them. Recap and preview segments also have skip buttons when the server supplies them. Skipping is manual.
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.
The server must finish analyzing the title before timestamps are available. Jellyframe reads Jellyfin's media segments, so other providers using the same API also work. If there are no timestamps or the request fails, playback continues without a skip button. Older plugin-specific APIs are not supported.
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
@@ -96,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 |
| --- | --- |
@@ -122,19 +160,19 @@ Move your head to look around. For a 180° video, the space behind you stays dar
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
@@ -149,6 +187,7 @@ Home always shows the newest additions first. The sort order does not affect Ran
- 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 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
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/*"
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=3
version/name="0.1.2"
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,6 +175,10 @@ 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); }
@@ -186,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);
}
@@ -204,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": "" }
+48 -38
View File
@@ -25,17 +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("video_shader_parameters"), &JellyframeMpv::video_shader_parameters);
ClassDB::bind_method(D_METHOD("playback_diagnostics"), &JellyframeMpv::playback_diagnostics);
ClassDB::bind_method(D_METHOD("stop_video"), &JellyframeMpv::stop_video);
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")));
@@ -43,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();
@@ -68,10 +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"},
{"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", JELLYFRAME_STREAM_USER_AGENT}
{"user-agent", JELLYXR_STREAM_USER_AGENT}
};
for (const auto &option : options) {
int error = mpv_set_option_string(player, option[0], option[1]);
@@ -102,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;
@@ -140,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);
@@ -156,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()) {
@@ -175,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-direct-yuv-1";
values["version"] = "vulkan-paced-yuv-2";
values["audio_position_ms"] = position;
if (vulkan_renderer.is_valid()) {
values["video_position_ms"] = vulkan_renderer->timestamp_ms();
@@ -205,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;
@@ -217,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;
@@ -248,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;
@@ -269,9 +272,11 @@ 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()) {
@@ -312,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;
@@ -336,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; }
@@ -410,6 +420,6 @@ void JellyframeMpv::_process(double) {
}
}
Dictionary JellyframeMpv::video_shader_parameters() const {
Dictionary JellyXRMpv::video_shader_parameters() const {
return vulkan_renderer.is_valid() ? vulkan_renderer->shader_parameters() : Dictionary();
}
+5 -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);
@@ -61,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) {
+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 {
+1 -1
View File
@@ -1,3 +1,3 @@
#pragma once
inline constexpr const char *JELLYFRAME_STREAM_USER_AGENT = "Jellyframe";
inline constexpr const char *JELLYXR_STREAM_USER_AGENT = "jellyXR";
+6 -3
View File
@@ -70,7 +70,7 @@ int VideoDecoder::open(const std::string &url, const std::string &authorization)
av_dict_set(&options, "headers", header.c_str(), 0);
}
av_dict_set(&options, "tls_verify", "1", 0);
av_dict_set(&options, "user_agent", JELLYFRAME_STREAM_USER_AGENT, 0);
av_dict_set(&options, "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);
@@ -150,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();
@@ -204,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();
@@ -219,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() {
+1
View File
@@ -124,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;
+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'
+4 -4
View File
@@ -14,13 +14,13 @@ compatibility/default_parent_skeleton_in_mesh_instance_3d=true
[application]
config/name="Jellyframe"
config/name="jellyXR"
config/description="A Jellyfin cinema for standalone VR."
config/version="0.1.2"
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,7 +29,7 @@ window/size/viewport_height=900
[editor_plugins]
enabled=PackedStringArray("res://addons/jellyframe_video/plugin.cfg")
enabled=PackedStringArray("res://addons/jellyxr_video/plugin.cfg")
[rendering]
+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")
+12 -2
View File
@@ -1,4 +1,4 @@
class_name JellyframeSettings
class_name JellyXRSettings
extends RefCounted
const PATH := "user://settings.cfg"
@@ -28,6 +28,9 @@ 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()
@@ -54,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)
@@ -62,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))
@@ -79,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)
+24 -2
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.2"' % [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
@@ -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", ""))
+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
+144 -32
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,9 +14,9 @@ var libraries_loaded := false
var library_generation := 0
var page_generation := 0
var page: VBoxContainer
var settings_return_page: Control
var settings_return_heading := ""
var settings_return_status := ""
var browsing_return_page: Control
var browsing_return_heading := ""
var browsing_return_status := ""
var status: Label
var keyboard: VRKeyboard
var material: ShaderMaterial
@@ -28,6 +28,7 @@ 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
@@ -45,6 +46,8 @@ 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()
@@ -54,15 +57,17 @@ 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()
@@ -75,8 +80,11 @@ 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)
@@ -102,16 +110,16 @@ func _setup_ui() -> void:
status = shell.status
shell.navigate.connect(_open_library)
shell.screen_settings.connect(_screen_settings)
shell.return_from_settings.connect(_return_from_settings)
shell.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(keep_settings_return: bool = false) -> void:
if not keep_settings_return and is_instance_valid(settings_return_page):
settings_return_page.queue_free()
settings_return_page = null
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
@@ -254,8 +262,8 @@ func _toggle_controller_library() -> void:
_show_library()
return
for child in page.get_children():
if child is SettingsView:
_return_from_settings()
if child is TracksView or child is SettingsView:
_restore_browsing_page()
return
_toggle_panel()
@@ -276,8 +284,8 @@ func _open_library(id: String, title: String) -> void:
api.library_sort_order = settings.library_sort_order
library.size_flags_vertical = Control.SIZE_EXPAND_FILL
library.status.connect(func(message: String):
if library == settings_return_page:
settings_return_status = message
if library == browsing_return_page:
browsing_return_status = message
else:
status.text = message)
library.selected.connect(_details)
@@ -324,6 +332,7 @@ func _details(item: Dictionary, season: Dictionary = {}) -> void:
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."
@@ -410,7 +419,8 @@ func _close_photo() -> void:
player_overlay.set_photo_mode(false)
func _start(item: Dictionary, from_start: bool = false) -> void:
if not from_start and playing and str(session.item.get("Id", "")) == str(item.get("Id", "")):
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)
@@ -438,9 +448,42 @@ func _start(item: Dictionary, from_start: bool = false) -> void:
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)
@@ -448,7 +491,7 @@ 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
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
@@ -488,11 +531,13 @@ func _player_action(action: String) -> void:
"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
@@ -526,14 +571,7 @@ func _toggle_settings() -> void:
func _screen_settings() -> void:
if api.token.is_empty() or not shell.signed_in: return
for child in page.get_children():
if child is LibraryView or child is DetailsView:
settings_return_page = child
settings_return_heading = shell.heading.text
settings_return_status = status.text
child.hide()
child.reparent(viewport)
_clear_page(true)
_preserve_browsing_page()
panel.show()
shell.heading.text = "Settings"
shell.set_settings_mode(true)
@@ -555,17 +593,27 @@ func _screen_settings() -> void:
page.add_child(view)
status.text = "Changes take effect as you make them."
func _return_from_settings() -> void:
if not is_instance_valid(settings_return_page):
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 := settings_return_page
settings_return_page = null
var previous := browsing_return_page
browsing_return_page = null
_clear_page()
previous.reparent(page)
previous.show()
shell.heading.text = settings_return_heading
status.text = settings_return_status
shell.heading.text = browsing_return_heading
status.text = browsing_return_status
panel.show()
func _resize_screen() -> void:
@@ -582,6 +630,7 @@ func _resize_screen() -> void:
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
@@ -650,6 +699,8 @@ func _duration_ms() -> int:
func _update_timeline() -> void:
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()
@@ -675,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 session.method != "Transcode" or session.is_hls():
if session.method == "DirectPlay" or session.is_hls():
video.seek(target)
return
seeking = true
@@ -689,9 +740,13 @@ 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({})
@@ -762,6 +817,8 @@ 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
@@ -794,6 +851,7 @@ 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
@@ -807,3 +865,57 @@ 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 _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
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
+2 -2
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())
+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()
+71 -4
View File
@@ -3,6 +3,7 @@ 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
@@ -14,12 +15,17 @@ 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)
@@ -28,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
@@ -46,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()
@@ -109,15 +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 := 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)
@@ -129,10 +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:
play_from_start_button = Widgets.button("Play from start", func(): play_from_start_requested.emit(item), actions)
play_from_start_button.custom_minimum_size = Vector2(200, 64)
_muted("Continue from " + DetailsMetadata.duration(position), parent, 20)
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()
+29 -4
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,18 +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 {}
if user.get("Played", false):
Widgets.watched_badge(frame)
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)
+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]
+55 -4
View File
@@ -19,6 +19,11 @@ 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:
@@ -59,6 +64,9 @@ func _ready() -> void:
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:
@@ -71,16 +79,53 @@ func _build_controls(parent: Control) -> void:
_add_control("Back 10 seconds", "back", "replay_10", row)
_add_control("Forward 30 seconds", "forward", "forward_30", row)
controls_spacer = row.add_spacer(false)
var tracks := _add_control("Audio and subtitles (coming soon)", "tracks", "subtitles", row)
tracks.disabled = true
tracks.mouse_default_cursor_shape = Control.CURSOR_ARROW
tracks.add_theme_color_override("icon_disabled_color", Color("8793a8"))
_add_control("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)
@@ -98,6 +143,7 @@ func _add_control(label: String, action_name: String, icon_name: String, parent:
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)
@@ -127,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 = ""
+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()
+5 -5
View File
@@ -9,7 +9,7 @@ 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
@@ -144,7 +144,7 @@ func _build_library(parent: VBoxContainer) -> void:
sort_choices.add_theme_constant_override("v_separation", 8)
sort_card.add_child(sort_choices)
var group := ButtonGroup.new()
for option in JellyframeSettings.LIBRARY_SORT_OPTIONS:
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())
@@ -184,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)
@@ -236,7 +236,7 @@ func _screen_position_hint() -> String:
func _build_video_format(parent: VBoxContainer) -> void:
parent = _card(parent, "Picture format")
_hint("Jellyframe detects the 3D format. Choose a different format if the picture looks wrong.", parent)
_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)
+2 -2
View File
@@ -39,9 +39,9 @@ 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
+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()
+1
View File
@@ -0,0 +1 @@
uid://ba0gb32v1fdln
+2 -1
View File
@@ -26,7 +26,7 @@ static func input(placeholder: String, parent: Node, secret: bool = false) -> Li
parent.add_child(result)
return result
static func watched_badge(image_frame: PanelContainer) -> void:
static func watched_badge(image_frame: PanelContainer) -> Control:
var overlay := Control.new()
overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE
image_frame.add_child(overlay)
@@ -41,6 +41,7 @@ static func watched_badge(image_frame: PanelContainer) -> void:
badge.offset_top = 8
badge.offset_right = -8
badge.offset_bottom = 36
return overlay
static func theme() -> Theme:
var result := Theme.new()
+1 -1
View File
@@ -4,7 +4,7 @@ extends Node3D
signal menu_requested
signal settings_requested
signal library_requested
var settings: JellyframeSettings
var settings: JellyXRSettings
var camera: Camera3D
var viewport: SubViewport
var panel: MeshInstance3D
+1 -1
View File
@@ -1,4 +1,4 @@
package nu.lizzy.jellyframe.video;
package nu.lizzy.jellyxr.video;
public final class PlaybackWatchdogTest {
public static void main(String[] args) {
+7
View File
@@ -20,10 +20,17 @@ func capture() -> void:
material.albedo_texture = texture
app.screen.material_override = material
app.player_overlay.update_time(1425000, 6780000, true, true)
app.player_overlay.timeline.update_buffer(2300000, 6780000, true)
await create_timer(0.5).timeout
await RenderingServer.frame_post_draw
root.get_texture().get_image().save_png("res://dist/player-preview.png")
app.player_overlay._show_tooltip(app.player_overlay.pause_button)
await RenderingServer.frame_post_draw
app.player_overlay.viewport.get_texture().get_image().save_png("res://dist/player-controls-preview.png")
app.session.method = "DirectPlay"
app.session.source = {"Bitrate": 25000000, "MediaStreams": [{"Type": "Video", "Width": 3840, "Height": 2160, "AverageFrameRate": 23.976, "Codec": "hevc"}]}
app.player_overlay.set_statistics_visible(true)
await create_timer(0.2).timeout
await RenderingServer.frame_post_draw
app.player_overlay.viewport.get_texture().get_image().save_png("res://dist/player-statistics-preview.png")
quit()
+60
View File
@@ -0,0 +1,60 @@
extends SceneTree
func _initialize() -> void:
call_deferred("capture")
func capture() -> void:
var app: Node = load("res://scenes/main.tscn").instantiate()
root.add_child(app)
app.set_process(false)
app.playing = true
app.session.item = {"Id": "fixture", "Name": "Example movie"}
app.session.source = {"DefaultAudioStreamIndex": 1, "MediaStreams": [
{"Type": "Audio", "Index": 1, "DisplayTitle": "English - AAC - Stereo"},
{"Type": "Audio", "Index": 2, "DisplayTitle": "Dutch - AC3 - 5.1"},
{"Type": "Subtitle", "Index": 3, "DisplayTitle": "English - SDH - SRT", "Codec": "srt"},
{"Type": "Subtitle", "Index": 4, "DisplayTitle": "Dutch - PGS", "Codec": "pgssub"}
]}
app._show_tracks()
await create_timer(0.3).timeout
await RenderingServer.frame_post_draw
app.viewport.get_texture().get_image().save_png("res://dist/tracks-preview.png")
var view: TracksView = app.page.get_child(0)
var scroll: ScrollContainer = view.get_child(2)
scroll.scroll_vertical = 1000
await create_timer(0.2).timeout
await RenderingServer.frame_post_draw
app.viewport.get_texture().get_image().save_png("res://dist/subtitle-settings-preview.png")
app.panel.hide()
app.viewport.get_parent().hide()
app.screen.show()
app.subtitle_overlay.text = "English subtitles in VR\nSize, color and timing can be adjusted."
await create_timer(0.2).timeout
await RenderingServer.frame_post_draw
root.get_texture().get_image().save_png("res://dist/subtitle-overlay-preview.png")
var preferred_y: float = app.subtitle_overlay.position.y
app.player_overlay.show()
await create_timer(0.2).timeout
await RenderingServer.frame_post_draw
var subtitle_bottom: float = app.subtitle_overlay.position.y + app.subtitle_overlay.get_aabb().position.y
var controls_top: float = app.player_overlay.position.y + app.player_overlay.panel_size.y / 2.0
assert(subtitle_bottom > controls_top, "Multiline subtitles stay above the visible player controls")
root.get_texture().get_image().save_png("res://dist/subtitle-controls-preview.png")
app.settings.subtitle_size = 80
app.settings.curvature = 1.2
app._resize_screen()
app.subtitle_overlay.text = "Large subtitles\nwith three lines\nstay above the controls."
await create_timer(0.2).timeout
await RenderingServer.frame_post_draw
subtitle_bottom = app.subtitle_overlay.position.y + app.subtitle_overlay.get_aabb().position.y
controls_top = app.player_overlay.position.y + app.player_overlay.panel_size.y / 2.0
assert(subtitle_bottom > controls_top, "Large multiline subtitles also clear controls on a curved screen")
app.player_overlay.hide()
await process_frame
await process_frame
assert(is_equal_approx(app.subtitle_overlay.position.y, preferred_y), "Hiding controls restores the chosen subtitle position")
print("Subtitle controls layout checks: PASS")
app.playing = false
app.queue_free()
await process_frame
quit()
+1
View File
@@ -0,0 +1 @@
uid://do8m6no23n03q
+1 -1
View File
@@ -45,7 +45,7 @@ class FixtureSession extends PlaybackSession:
if hold_stop: await release_stop
await get_tree().process_frame
func open(selected: Dictionary, _transcode: bool = false) -> void:
func open(selected: Dictionary, _transcode: bool = false, _continuing: bool = false) -> void:
item = selected
opened.append(selected)
+59 -1
View File
@@ -1,6 +1,22 @@
extends SceneTree
class FixtureApi extends JellyfinClient:
var playback_items: Array = [{"Id": "first", "SeasonId": "season-one", "UserData": {"PlaybackPositionTicks": 0}}]
var fail_playback := false
var playback_calls := 0
var hold_playback := false
signal release_playback
func series_playback(_series_id: String) -> Dictionary:
playback_calls += 1
var tree := get_tree()
if hold_playback: await release_playback
await tree.process_frame
return {"ok": false} if fail_playback else {"ok": true, "data": {"Items": playback_items}}
func next_up(_series_id: String) -> Dictionary:
return {"ok": true, "data": {"Items": []}}
func seasons(_series_id: String) -> Dictionary:
return {"ok": true, "data": {"Items": []}}
@@ -60,6 +76,7 @@ func run_checks() -> void:
var view := show_details(shell, item)
await settle()
check(view.play_button.text == "Resume", "Saved playback must offer Resume.")
check(view.version_button == null, "A title without multiple versions does not show Choose version.")
view.play_button.pressed.emit()
check(selected == [item], "Resume must pass the original item to playback.")
check(view.play_from_start_button != null and view.play_from_start_button.text == "Play from start", "Saved playback also offers Play from start.")
@@ -77,13 +94,24 @@ func run_checks() -> void:
root.get_texture().get_image().save_png("res://dist/details-preview.png")
item.Name = "A very long movie title that should wrap clearly without hiding its final words"
item.Overview = "A long synopsis must remain available while scrolling. ".repeat(30)
item.MediaSources = [{"Id": "first", "Name": "1080p"}, {"Id": "second", "Name": "4K"}]
view = show_details(shell, item)
await settle()
var version_requests: Array = []
view.version_requested.connect(func(chosen: Dictionary): version_requests.append(chosen))
check(view.version_button != null, "Multiple versions offer a separate Choose version button.")
view.version_button.pressed.emit()
check(version_requests == [item] and selected.size() == 1, "Choose version requests a picker without triggering Play.")
if "--capture" in OS.get_cmdline_user_args():
await RenderingServer.frame_post_draw
root.get_texture().get_image().save_png("res://dist/details-versions-preview.png")
for width in [1280, 960]:
root.size.x = width
await settle()
check(view.body.size.x <= view.scroll.size.x, "Details must fit the available width at %d pixels." % width)
check(view.title_label.get_global_rect().end.x <= view.scroll.get_global_rect().end.x, "Long titles must not escape the details panel.")
check(view.play_from_start_button.get_global_rect().end.x <= view.scroll.get_global_rect().end.x, "The restart button fits narrow details panels.")
check(view.version_button.get_global_rect().end.x <= view.scroll.get_global_rect().end.x, "Choose version fits narrow details panels.")
root.size.x = 1536
view = show_details(shell, {"Id": "plain", "Name": "A movie without artwork", "Type": "Movie", "Overview": null, "UserData": null, "Genres": null, "People": null})
await settle()
@@ -92,7 +120,37 @@ func run_checks() -> void:
check(view.overview_label.text == "No description available.", "Missing metadata must have a readable fallback.")
view = show_details(shell, {"Id": "series", "Name": "Example series", "Type": "Series"})
await settle()
check(view.play_button == null, "Series must select episodes instead of playing the series itself.")
check(view.play_button != null and view.play_button.text == "Play series", "Series headers offer a Play series button.")
selected.clear()
view.play_button.pressed.emit()
await settle()
check(selected.size() == 1 and selected.back().Id == "first" and selected.back().Type == "Episode" and selected.back().SeriesId == "series", "New series play their first episode rather than the series item.")
var series_api := view.api as FixtureApi
series_api.playback_items = [{"Id": "later", "SeasonId": "season-two", "UserData": {"PlaybackPositionTicks": 123456}}]
view.play_button.pressed.emit()
await settle()
check(selected.back().Id == "later" and selected.back().SeasonId == "season-two" and selected.back().UserData.PlaybackPositionTicks == 123456, "Series playback fetches current progress and preserves the episode resume position.")
series_api.fail_playback = true
view.play_button.pressed.emit()
await settle()
check(selected.size() == 2 and view.series_playback_status.visible and not view.play_button.disabled, "Failed lookups allow retry without starting playback.")
series_api.fail_playback = false
series_api.playback_items = []
view.play_button.pressed.emit()
await settle()
check(selected.size() == 2 and view.series_playback_status.text == "No episodes available for this series.", "Empty series show a message without playing the series item.")
series_api.hold_playback = true
view.play_button.pressed.emit()
var calls := series_api.playback_calls
view.play_button.pressed.emit()
check(series_api.playback_calls == calls and view.play_button.disabled, "Repeated presses cannot queue duplicate playback requests.")
view.get_parent().remove_child(view)
series_api.release_playback.emit()
await settle()
check(selected.size() == 2, "Leaving the details view prevents late responses from starting playback.")
view.free()
view = show_details(shell, {"Id": "series", "Name": "Example series", "Type": "Series"})
await settle()
check(view.play_from_start_button == null, "Series headers do not offer title playback actions.")
check(view.series_browser != null, "Series must show a season browser.")
check(view.series_browser.season_grid.get_child(0).text == "No seasons available.", "An empty series must have a readable fallback.")
+58 -1
View File
@@ -6,6 +6,7 @@ var incoming := ""
var payload := "{}"
var code := 200
var last_request := ""
var queued_payloads: Array[String] = []
var failures := 0
func _initialize() -> void:
@@ -31,6 +32,7 @@ func _process(_delta: float) -> bool:
content_length = int(line.split(":")[1].strip_edges())
if incoming.substr(end + 4).to_utf8_buffer().size() < content_length: return false
last_request = incoming
if not queued_payloads.is_empty(): payload = queued_payloads.pop_front()
var response := "HTTP/1.1 %d Test\r\nContent-Type: application/json\r\nContent-Length: %d\r\nConnection: close\r\n\r\n%s" % [code, payload.to_utf8_buffer().size(), payload]
peer.put_data(response.to_utf8_buffer())
peer.disconnect_from_host()
@@ -49,6 +51,17 @@ func run_checks() -> void:
var body: Dictionary = JSON.parse_string(last_request.split("\r\n\r\n", true, 1)[1])
check(body.Username == 'a"b' and body.Pw == "test-only-password", "Credentials are JSON encoded")
payload = '{"Items":[],"TotalRecordCount":0}'
queued_payloads = [payload, '{"Items":[{"Id":"first","SeasonId":"season-one","UserData":{"Played":true}}]}']
result = await api.series_playback("finished show")
check(result.ok and result.data.Items[0].Id == "first", "Completed series fall back to their first episode")
check(last_request.begins_with("GET /jellyfin/Shows/finished%20show/Episodes?") and "Limit=1" in last_request and "SortBy=ParentIndexNumber%2CIndexNumber" in last_request and "SortOrder=Ascending" in last_request and "IsMissing=false" in last_request, "Fallback loads the first available episode in season and episode order")
payload = '{"Items":[],"TotalRecordCount":0}'
await api.next_up("finished show")
check(last_request.begins_with("GET /jellyfin/Shows/NextUp?"), "The Next Up section does not fall back to watched episodes")
code = 500
result = await api.series_playback("offline show")
check(not result.ok and last_request.begins_with("GET /jellyfin/Shows/NextUp?"), "Failed Next Up requests do not restart a series")
code = 200
await api.library("hello & world", 24)
check("SearchTerm=hello%20%26%20world" in last_request, "Search escaping survives HTTP")
check('Token="fixture-token"' in last_request, "Authenticated calls send the token header")
@@ -61,9 +74,26 @@ func run_checks() -> void:
check(last_request.begins_with("GET /jellyfin/UserViews?") and "UserId=fixture-user" in last_request, "Sidebar loads the signed-in user's libraries")
await api.seasons("show id")
check(last_request.begins_with("GET /jellyfin/Shows/show%20id/Seasons?"), "Seasons load from the selected series with an escaped ID")
payload = '{"Items":[{"Id":"next","UserData":{"PlaybackPositionTicks":100}}]}'
result = await api.next_up("show & id")
check(result.ok and last_request.begins_with("GET /jellyfin/Shows/NextUp?") and "SeriesId=show%20%26%20id" in last_request and "UserId=fixture-user" in last_request, "Next Up is scoped to the selected series and signed-in user")
check("DisableFirstEpisode" not in last_request and "EnableResumable=true" in last_request, "A partially watched first episode remains resumable")
payload = '{"Items":[{"Id":"next","UserData":{"PlaybackPositionTicks":0}}]}'
result = await api.series_playback("new show")
check(result.ok and result.data.Items[0].Id == "next" and "SeriesId=new%20show" in last_request and "DisableFirstEpisode" not in last_request, "Series playback allows the first episode for a new show")
await api.next_up("show id")
check("DisableFirstEpisode=true" in last_request and "EnableRewatching=false" in last_request, "Unstarted episodes require prior series watch history")
payload = '{"Items":[],"TotalRecordCount":0}'
await api.episodes("show id", "season & id", 100)
check("SeasonId=season%20%26%20id" in last_request and "StartIndex=100" in last_request, "Episode pagination stays inside the selected season")
check("Fields=Overview%2CUserData" in last_request and "IsMissing=false" in last_request, "Episode cards request summaries and exclude missing episodes")
check("Fields=Overview%2CUserData%2CMediaSources" in last_request and "IsMissing=false" in last_request, "Episode cards request versions and summaries and exclude missing episodes")
payload = '{"Played":true,"PlaybackPositionTicks":0}'
code = 200
result = await api.set_watched("movie id", true)
check(result.ok and result.data.Played and last_request.begins_with("POST /jellyfin/Users/fixture-user/PlayedItems/movie%20id "), "Mark watched sends an authenticated POST for the selected item")
payload = '{"Played":false,"PlaybackPositionTicks":0}'
result = await api.set_watched("episode id", false)
check(result.ok and not result.data.Played and last_request.begins_with("DELETE /jellyfin/Users/fixture-user/PlayedItems/episode%20id "), "Mark unwatched sends DELETE and returns updated user data")
code = 401
payload = ""
result = await api.request("/Users/Me")
@@ -75,10 +105,37 @@ func run_checks() -> void:
payload = "<html>Wrong proxy route</html>"
result = await api.request("/Users/Me")
check(not result.ok, "Non-JSON proxy responses are rejected")
await check_playback_reports(api)
server.stop()
print("HTTP integration checks: %s" % ("PASS" if failures == 0 else "%d FAILED" % failures))
quit(1 if failures else 0)
func check_playback_reports(api: JellyfinClient) -> void:
var session := PlaybackSession.new()
session.api = api
session.item = {"Id": "fixture-item"}
session.source = JSON.parse_string('{"Id":"fixture-source","DefaultAudioStreamIndex":1}')
session.session_id = "fixture-session"
root.add_child(session)
code = 204
payload = ""
for event in ["Playing", "Progress", "Stopped"]:
await session.report(event)
var body := last_request.split("\r\n\r\n", true, 1)[1]
check('"AudioStreamIndex":1,' in body, "%s sends the parsed server audio index as a JSON integer, not 1.0" % event)
session.source["DefaultAudioStreamIndex"] = null
await session.report("Playing")
check('"AudioStreamIndex"' not in last_request, "Sources without a default audio track omit the optional index")
session.audio_index = 3
await session.report("Progress")
check('"AudioStreamIndex":3,' in last_request, "Explicit audio selection is reported as an integer")
var errors: Array[String] = []
session.reporting_failed.connect(func(message: String): errors.append(message))
code = 400
await session.report("Progress")
check(errors.size() == 1 and "HTTP 400" in errors[0], "Rejected playback reports are exposed instead of silently ignored")
session.queue_free()
func check(condition: bool, message: String) -> void:
if not condition:
failures += 1
+2 -2
View File
@@ -46,7 +46,7 @@ class DependencyChecks(unittest.TestCase):
linux_bundle.verify_bundle(Path("plugin.so"), Path("bundle/lib"), ["libmpv.so.2"])
@unittest.skipUnless(platform.system() == "Linux" and os.getenv("JELLYFRAME_BUNDLE_INTEGRATION") == "1", "Requires Linux build dependencies")
@unittest.skipUnless(platform.system() == "Linux" and os.getenv("JELLYXR_BUNDLE_INTEGRATION") == "1", "Requires Linux build dependencies")
class BundleIntegration(unittest.TestCase):
def test_relocated_bundle_loads_decoder_and_preserves_system_package(self):
arch = "arm64" if platform.machine() in ("aarch64", "arm64") else "x86_64"
@@ -54,7 +54,7 @@ class BundleIntegration(unittest.TestCase):
root = Path(directory)
original = root / "system"
original.mkdir()
plugin = original / ("jellyframe_mpv.linux." + arch + ".so")
plugin = original / ("jellyxr_mpv.linux." + arch + ".so")
source = root / "probe.c"
source.write_text(
'#include <mpv/client.h>\n'
+1 -1
View File
@@ -63,7 +63,7 @@ func run_check() -> void:
return
DisplayServer.window_set_vsync_mode(DisplayServer.VSYNC_DISABLED)
Engine.max_fps = 120
decoder = ClassDB.instantiate("JellyframeMpv")
decoder = ClassDB.instantiate("JellyXRMpv")
root.add_child(decoder)
screen = preload("res://tests/pc_video_test_screen.gd").new(decoder)
root.add_child(screen)
+1 -1
View File
@@ -23,7 +23,7 @@ func run_check() -> void:
var reference := Image.load_from_file(args[1])
if not require(reference != null and not reference.is_empty(), "Read the FFmpeg tone-mapping reference"):
return
decoder = ClassDB.instantiate("JellyframeMpv")
decoder = ClassDB.instantiate("JellyXRMpv")
root.add_child(decoder)
var screen := preload("res://tests/pc_video_test_screen.gd").new(decoder, Vector2i(640, 192))
root.add_child(screen)
+29
View File
@@ -0,0 +1,29 @@
#include "../native/pc/playback_clock.h"
#include <cassert>
#include <iostream>
int main() {
PlaybackClock clock;
clock.reset(1000, 5000);
assert(clock.position(5016) == 1016);
assert(clock.position(5033) == 1033);
clock.synchronize(1040, 5050);
assert(clock.position(5050) == 1048);
assert(clock.position(5066) == 1064);
clock.synchronize(1110, 5100);
assert(clock.position(5100) == 1100);
// A missing audio update must not let video run indefinitely ahead.
assert(clock.position(6000) == 1350);
clock.synchronize(1400, 6000);
assert(clock.position(6016) == 1368);
// Pause, buffering, and seeking discard the previous extrapolation.
clock.reset(1400, 7000);
clock.reset(1400, 8000);
assert(clock.position(8000) == 1400);
assert(clock.position(8016) == 1416);
clock.reset(3000, 9000);
assert(clock.position(9016) == 3016);
clock.synchronize(10000, 9100);
assert(clock.position(9100) == 10000);
std::cout << "PC playback clock checks: PASS\n";
}
+1 -1
View File
@@ -8,7 +8,7 @@ import subprocess
import threading
AUTHORIZATION = 'MediaBrowser Client="Jellyframe", Token="test-token"'
AUTHORIZATION = 'MediaBrowser Client="jellyXR", Token="test-token"'
class VideoHandler(SimpleHTTPRequestHandler):
+8 -3
View File
@@ -24,7 +24,7 @@ func wait_until(predicate: Callable, seconds: float = 10.0) -> bool:
return predicate.call()
func run_checks() -> void:
if not ClassDB.class_exists("JellyframeMpv"):
if not ClassDB.class_exists("JellyXRMpv"):
push_error("Build the PC video plugin before running this test.")
quit(1)
return
@@ -33,7 +33,7 @@ func run_checks() -> void:
check(not backend.available(), "Desktop preview must not enable playback")
check(not backend.has_android_services(), "PC playback must not call Android session services")
backend.queue_free()
decoder = ClassDB.instantiate("JellyframeMpv")
decoder = ClassDB.instantiate("JellyXRMpv")
root.add_child(decoder)
if DisplayServer.get_name() != "headless":
screen = preload("res://tests/pc_video_test_screen.gd").new(decoder, Vector2i(1920, 1080))
@@ -45,7 +45,7 @@ func run_checks() -> void:
decoder.texture_changed.connect(func(value: Texture2D): texture = value)
var url := ProjectSettings.globalize_path("res://tests/fixtures/stereo_test.mp4")
var authorization := ""
var expected_renderer := "software" if DisplayServer.get_name() == "headless" or OS.get_environment("JELLYFRAME_PC_RENDERER") == "software" else "opengl"
var expected_renderer := "software" if DisplayServer.get_name() == "headless" or OS.get_environment("JELLYXR_PC_RENDERER") == "software" else "opengl"
if expected_renderer != "software" and RenderingServer.get_current_rendering_driver_name() == "vulkan":
expected_renderer = "vulkan"
for argument in OS.get_cmdline_user_args():
@@ -79,12 +79,17 @@ func run_checks() -> void:
decoder.set_paused(true)
await create_timer(0.4).timeout
var paused_at: int = decoder.position_ms()
var paused_video: int = decoder.playback_diagnostics().get("video_position_ms", -1)
await create_timer(0.4).timeout
check(absi(decoder.position_ms() - paused_at) < 100, "Pause stops the clock")
if expected_renderer == "vulkan":
check(decoder.playback_diagnostics().get("video_position_ms", -1) == paused_video, "Pause stops video presentation")
decoder.seek_to(3000)
check(decoder.is_buffering(), "Native seeking shows buffering while playback restarts")
check(await wait_until(func(): return absi(decoder.position_ms() - 3000) < 250), "Seeking works while paused")
check(await wait_until(func(): return not decoder.is_buffering()), "Seek completion clears the loading state")
if expected_renderer == "vulkan":
check(await wait_until(func(): return absi(int(decoder.playback_diagnostics().get("video_position_ms", -10000)) - 3000) < 250), "Paused seeking presents the target video frame")
decoder.set_paused(false)
check(await wait_until(func(): return decoder.position_ms() > 3400), "Playback continues after seeking")
decoder.seek_to(decoder.duration_ms() - 500)
+5 -1
View File
@@ -13,14 +13,16 @@ func run_check() -> void:
var url := "res://build/gpu-worker/8k-av1-test.mp4"
var seconds := 5.0
var minimum_fps := 0.0
var maximum_gap_p95_ms := 0.0
for argument in OS.get_cmdline_user_args():
if argument.begins_with("--video="): url = argument.trim_prefix("--video=")
if argument.begins_with("--seconds="): seconds = float(argument.trim_prefix("--seconds="))
if argument.begins_with("--minimum-fps="): minimum_fps = float(argument.trim_prefix("--minimum-fps="))
if argument.begins_with("--maximum-gap-p95-ms="): maximum_gap_p95_ms = float(argument.trim_prefix("--maximum-gap-p95-ms="))
DisplayServer.window_set_vsync_mode(DisplayServer.VSYNC_DISABLED)
OS.low_processor_usage_mode = false
Engine.max_fps = 120
decoder = ClassDB.instantiate("JellyframeMpv")
decoder = ClassDB.instantiate("JellyXRMpv")
root.add_child(decoder)
screen = preload("res://tests/pc_video_test_screen.gd").new(decoder)
root.add_child(screen)
@@ -72,6 +74,8 @@ func run_check() -> void:
stats["window_focused"] = DisplayServer.window_is_focused()
print("Video performance: ", JSON.stringify(stats))
var passed: bool = errors.is_empty() and stats.get("renderer") == "vulkan" and float(stats["presented_fps"]) >= minimum_fps
if maximum_gap_p95_ms > 0:
passed = passed and not video_intervals.is_empty() and float(stats["video_gap_p95_ms"]) <= maximum_gap_p95_ms
decoder.stop_video()
decoder.queue_free()
await process_frame
+1 -1
View File
@@ -14,7 +14,7 @@ func run_check() -> void:
if argument == "--expect-buffer-free": expect_buffer_free = true
OS.low_processor_usage_mode = false
Engine.max_fps = 120
decoder = ClassDB.instantiate("JellyframeMpv")
decoder = ClassDB.instantiate("JellyXRMpv")
root.add_child(decoder)
decoder.failed.connect(func(value: String): errors.append(value))
var error: String = decoder.open_video(url, "", 0)
+1 -1
View File
@@ -2,7 +2,7 @@ extends SceneTree
class PreviewVideo extends PcVideo:
func available() -> bool:
return ClassDB.class_exists("JellyframeMpv")
return ClassDB.class_exists("JellyXRMpv")
var video: PcVideo
var screen: SubViewport
+1 -1
View File
@@ -38,7 +38,7 @@ func run_check() -> void:
for argument in OS.get_cmdline_user_args():
if argument.begins_with("--video="):
url = argument.trim_prefix("--video=")
decoder = ClassDB.instantiate("JellyframeMpv")
decoder = ClassDB.instantiate("JellyXRMpv")
root.add_child(decoder)
screen = preload("res://tests/pc_video_test_screen.gd").new(decoder)
root.add_child(screen)
+1 -1
View File
@@ -44,7 +44,7 @@ func check_corners(expected: Array, label: String) -> void:
push_error("%s corner %d: expected %s, got %s" % [label, index, color, actual])
func run_checks() -> void:
var decoder := ClassDB.instantiate("JellyframeMpv") as Node
var decoder := ClassDB.instantiate("JellyXRMpv") as Node
root.add_child(decoder)
screen = preload("res://tests/pc_video_test_screen.gd").new(decoder, Vector2i(64, 64))
root.add_child(screen)
+29 -1
View File
@@ -4,9 +4,11 @@ class FixtureClient extends JellyfinClient:
var bodies: Array[Dictionary] = []
var direct := true
var transcode_path := "/Videos/fixture/master.m3u8"
var sources: Array = []
func request(path: String, _method: HTTPClient.Method = HTTPClient.METHOD_GET, body: Dictionary = {}) -> Dictionary:
bodies.append(body)
if not sources.is_empty(): return {"ok": true, "data": {"MediaSources": sources, "PlaySessionId": "versions"}}
return {"ok": true, "data": {"MediaSources": [{"Id": "fixture-source", "SupportsDirectPlay": direct, "TranscodingUrl": transcode_path}]}}
func _initialize() -> void:
@@ -43,13 +45,39 @@ func run_checks() -> void:
assert(api.bodies[1].DeviceProfile.TranscodingProfiles[0].Conditions == retry_profile.TranscodingProfiles[0].Conditions, "The playback request sends the conversion limits on the current platform")
await check_playback_choices(session, api, streams)
await check_transcode_resolutions(session, api, streams)
await check_media_versions(session, api, streams)
session.queue_free()
api.queue_free()
print("Playback profile checks: PASS")
quit()
func check_media_versions(session: PlaybackSession, api: FixtureClient, streams: Array) -> void:
api.sources = [
{"Id": "first", "SupportsDirectPlay": true, "TranscodingUrl": "/first.m3u8"},
{"Id": "second", "SupportsDirectPlay": true, "TranscodingUrl": "/second.m3u8"}
]
var selected := {"Id": "fixture", "SelectedMediaSourceId": "second", "UserData": {"PlaybackPositionTicks": 450000000}}
await session.open(selected)
assert(api.bodies.back().MediaSourceId == "second", "Negotiation requests the selected version")
assert("MediaSourceId=second" in streams.back()[0] and streams.back()[1] == 45000, "Direct playback resumes the selected version even when it is returned second")
await session.report("Playing")
assert(api.bodies.back().MediaSourceId == "second", "Playback reports identify the selected version")
await session.retry_transcode()
assert(api.bodies.back().MediaSourceId == "second" and streams.back()[0].contains("/second.m3u8"), "Decoder fallback keeps the selected version")
session.position_ticks = 900000000
await session.open(selected, true)
assert(api.bodies.back().MediaSourceId == "second" and streams.back()[1] == 90000, "Transcoded seeks keep the version and absolute position")
var errors: Array = []
session.failed.connect(func(message: String): errors.append(message))
api.sources.remove_at(1)
var count := streams.size()
await session.open(selected)
assert(streams.size() == count and errors.size() == 1, "A missing version fails instead of playing another version")
await session.open({"Id": "another-item"})
assert(not api.bodies.back().has("MediaSourceId") and session.media_source_id == "first", "A new item clears the previous version choice")
func check_playback_choices(session: PlaybackSession, api: FixtureClient, streams: Array) -> void:
session.settings = JellyframeSettings.new()
session.settings = JellyXRSettings.new()
session.settings.playback_mode = "original"
api.direct = true
await session.open({"Id": "fixture"})
+67 -2
View File
@@ -11,7 +11,7 @@ class FixtureApi extends JellyfinClient:
func details(id: String) -> Dictionary:
detail_calls.append(id)
await get_tree().process_frame
return {"ok": true, "data": {"Id": id, "Name": "Selected series" if id == "show" else "Selected movie", "Type": "Series" if id == "show" else "Movie", "UserData": {"PlaybackPositionTicks": 7200000000}}}
return {"ok": true, "data": {"Id": id, "Name": "Selected series" if id == "show" else "Selected movie", "Type": "Series" if id == "show" else "Movie", "MediaSources": [], "UserData": {"PlaybackPositionTicks": 7200000000}}}
func seasons(_series_id: String) -> Dictionary:
await get_tree().process_frame
@@ -30,7 +30,7 @@ class FixtureVideo extends VideoBackend:
class FixtureSession extends PlaybackSession:
var opens := 0
func report(_event: String, _paused: bool = false) -> void: pass
func open(selected: Dictionary, _transcode: bool = false) -> void:
func open(selected: Dictionary, _transcode: bool = false, _continuing: bool = false) -> void:
item = selected
opens += 1
@@ -95,9 +95,36 @@ func run_checks() -> void:
check(app.shell.buttons["movies"] == library_button, "Returning Home preserves the loaded library buttons")
check(app.shell.settings_navigation.get_child_count() == 0 and app.shell.settings_button.visible and app.shell.logout.visible, "Leaving settings clears categories and restores normal navigation")
app.playing = true
app._show_playback()
var statistics_generation: int = app.page_generation
for attempt in range(3):
app.player_overlay.statistics_button.pressed.emit()
await settle()
check(app.player_overlay.statistics_panel.visible, "The statistics button opens its player panel")
check(app.page.get_child(0) == browsing and app.page_generation == statistics_generation, "Statistics never replace or rebuild the library page")
app._toggle_controller_library()
await settle()
check(not app.player_overlay.statistics_panel.visible, "Opening the library closes statistics")
check(app.page.get_child(0) == browsing and browsing.search == "Selected movie" and browsing.scroll.scroll_vertical == browsing_scroll, "Statistics preserve library search and scroll")
app._show_playback()
for attempt in range(3):
app._show_tracks()
await settle()
if attempt == 1: app._show_playback()
if attempt == 2:
app._screen_settings()
app._show_tracks()
app._toggle_controller_library()
await settle()
check(app.page.get_child(0) == browsing, "Returning from tracks preserves the original browsing page")
check(browsing.search == "Selected movie" and browsing.scroll.scroll_vertical == browsing_scroll, "Tracks preserve library search and scroll, including through settings")
app.playing = false
await app._details({"Id": "movie"})
var movie_view: DetailsView = app.page.get_child(0)
await app._start(movie_view.item)
app._show_tracks()
app._player_action("library")
await settle()
check(app.page.get_child(0) == movie_view, "The player's Library button returns to the selected movie details")
@@ -198,6 +225,20 @@ func run_checks() -> void:
check(app.panel.visible and app.page.get_child(0) == controller_library, "After Y opens and closes settings, B restores the previous library page")
check(app.shell.libraries.visible and not app.shell.settings_navigation.visible, "B restores library navigation after hidden settings")
check(app.playing and video.stops == stops_before_library and session.opens == opens_before_library and video.paused == paused_before_library, "Returning from settings with B leaves playback uninterrupted")
for hide_tracks in [false, true]:
app._show_tracks()
check(app.page.get_child(0) is TracksView, "Audio and subtitles opens its own page")
if hide_tracks: app._show_playback()
app.world._controller_button_pressed("by_button", &"right_hand")
check(app.panel.visible and app.page.get_child(0) is LibraryView, "B opens the library after either visible or hidden track controls")
check(app.shell.selected_id == "movies", "Returning from tracks keeps the selected library")
check(app.page.get_child(0) == controller_library, "Returning from tracks retains the browsing page")
var tracks_return_library: Node = app.page.get_child(0)
app.world._controller_button_pressed("by_button", &"right_hand")
check(not app.panel.visible, "B closes the library after returning from tracks")
app.world._controller_button_pressed("by_button", &"right_hand")
check(app.panel.visible and app.page.get_child(0) == tracks_return_library, "B reopens the library instead of the old track page")
check(app.playing and video.stops == stops_before_library and session.opens == opens_before_library and video.paused == paused_before_library, "Returning from tracks with B leaves playback uninterrupted")
app.panel.hide()
app.playing = false
app.screen.hide()
@@ -272,6 +313,30 @@ func run_checks() -> void:
root.use_xr = false
await settle()
app.playing = false
api.token = "fixture"
var versions := {"Id": "versions", "Type": "Movie", "Name": "Multiple versions", "UserData": {"PlaybackPositionTicks": 7200000000}, "MediaSources": [{"Id": "one", "Name": "1080p"}, {"Id": "two", "Name": "4K director's cut"}]}
var opens_before_versions := session.opens
await app._start(versions, true)
await settle()
check(session.opens == opens_before_versions + 1 and not session.item.has("SelectedMediaSourceId"), "Play starts the default version without a picker")
app._choose_media_version(versions)
await settle()
check(app.panel.visible and session.opens == opens_before_versions + 1, "Choose version opens the picker without starting playback")
var version_scroll: ScrollContainer = app.page.get_child(3)
var choices: VBoxContainer = version_scroll.get_child(0)
check(choices.get_child_count() == 2 and choices.get_child(1).text == "4K director's cut", "The picker shows the server's version names")
if "--capture-versions" in OS.get_cmdline_user_args():
root.size = Vector2i(1536, 960)
app.viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
app.shell.show()
await settle()
await RenderingServer.frame_post_draw
app.viewport.get_texture().get_image().save_png("res://dist/media-versions-preview.png")
choices.get_child(1).pressed.emit()
await settle()
check(session.item.get("SelectedMediaSourceId") == "two" and session.item.UserData.PlaybackPositionTicks == 7200000000, "Choosing another version switches playback and preserves saved progress")
check(versions.UserData.PlaybackPositionTicks == 7200000000 and not versions.has("SelectedMediaSourceId"), "Version selection leaves the original item unchanged")
app.playing = false
app.queue_free()
await process_frame
+23 -2
View File
@@ -42,20 +42,41 @@ func run_checks() -> void:
pointer.sample_trigger(0.0)
pointer.aim(tracks.get_global_rect().get_center(), true)
await process_frame
check(overlay.tooltip.text == "Audio and subtitles (coming soon)", "The disabled track placeholder explains its unavailable state on hover")
check(overlay.tooltip.text == "Audio and subtitles", "The track button identifies its action on hover")
pointer.sample_trigger(1.0)
pointer.aim(tracks.get_global_rect().get_center(), true)
pointer.sample_trigger(0.0)
pointer.aim(tracks.get_global_rect().get_center(), true)
await process_frame
check(actions.is_empty(), "The track placeholder cannot trigger playback actions")
check(actions == ["tracks"], "The track button opens audio and subtitle controls")
pointer.cancel()
button.grab_focus()
await process_frame
check(overlay.tooltip.text == "Play", "Keyboard focus also exposes the button's label")
var session := PlaybackSession.new()
var video := VideoBackend.new()
session.source = {"Bitrate": 25000000, "MediaStreams": [{"Type": "Video", "Width": 3840, "Height": 2160, "AverageFrameRate": 23.976, "Codec": "hevc"}]}
overlay.statistics_provider = func(): return preload("res://scripts/ui/playback_statistics.gd").snapshot(session, video)
overlay.statistics_button.pressed.emit()
await process_frame
await process_frame
check(overlay.statistics_panel.visible and overlay.statistics_button.button_pressed, "The statistics button opens and marks the panel active")
check(overlay.statistics_values["Source video"].text == "3840 x 2160 / 23.976 fps", "Statistics show source resolution and frame rate")
check(overlay.statistics_values["Dropped frames"].text == "Not reported", "Missing decoder counters are not presented as zero")
video.paused = true
overlay._process(1.0)
check(overlay.statistics_values["Player state"].text == "Paused", "Open statistics refresh playback state")
check(overlay.statistics_button.get_global_rect().end.x <= overlay.viewport.size.x, "The rightmost button fits inside the player bar")
check(overlay.statistics_panel.get_global_rect().end.y <= overlay.viewport.size.y, "The statistics panel fits inside the player viewport")
overlay.statistics_button.pressed.emit()
check(not overlay.statistics_panel.visible and overlay.viewport.size.y == 300, "Pressing statistics again restores the compact player")
overlay.set_statistics_visible(true)
overlay.hide()
await process_frame
check(overlay.tooltip.text.is_empty(), "Hiding controls clears the tooltip")
check(not overlay.statistics_panel.visible, "Hiding playback controls also dismisses statistics")
video.free()
session.free()
var row := button.get_parent()
for control in row.get_children():
if not control is Button: continue
Loaded 100 of 119 files, more files were not shown because too many files have changed in this diff. Show more