mirror of
https://github.com/Cuteminded/jellyframe.git
synced 2026-10-27 23:17:27 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
504284af5b | ||
|
|
4d43b8c446 | ||
|
|
addd5215e5 | ||
|
|
9dd4283af5 | ||
|
|
906276ca4d | ||
|
|
e0306de3d0 | ||
|
|
e9883e8f5a | ||
|
|
ec9799e7e4 | ||
|
|
9c6668d0ea | ||
|
|
5c7aa5673f | ||
|
|
6833640385 | ||
|
|
ce95b7eb6b | ||
|
|
70871b3e4c | ||
|
|
fbbd9d7c4e | ||
|
|
caad1138c5 |
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
@@ -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 |
File renamed without changes.
+3
-3
@@ -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
-3
@@ -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
@@ -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]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
plugins { id 'com.android.library' }
|
||||
|
||||
android {
|
||||
namespace 'nu.lizzy.jellyframe.video'
|
||||
namespace 'nu.lizzy.jellyxr.video'
|
||||
compileSdk 35
|
||||
defaultConfig { minSdk 29 }
|
||||
compileOptions {
|
||||
@@ -17,6 +17,6 @@ dependencies {
|
||||
tasks.register('stagePlugin', Copy) {
|
||||
dependsOn 'assembleRelease'
|
||||
from layout.buildDirectory.file('outputs/aar/android-release.aar')
|
||||
into '../../addons/jellyframe_video/bin'
|
||||
rename { 'jellyframe-video-release.aar' }
|
||||
into '../../addons/jellyxr_video/bin'
|
||||
rename { 'jellyxr-video-release.aar' }
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-feature android:name="android.hardware.vr.headtracking" android:version="1" android:required="true" />
|
||||
<application android:usesCleartextTraffic="true" android:allowBackup="false">
|
||||
<meta-data android:name="org.godotengine.plugin.v2.JellyframeVideo"
|
||||
android:value="nu.lizzy.jellyframe.video.JellyframeVideo" />
|
||||
<meta-data android:name="org.godotengine.plugin.v2.JellyXRVideo"
|
||||
android:value="nu.lizzy.jellyxr.video.JellyXRVideo" />
|
||||
<meta-data android:name="com.oculus.supportedDevices" android:value="quest3|questpro|quest2" />
|
||||
</application>
|
||||
</manifest>
|
||||
+16
-6
@@ -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
-1
@@ -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;
|
||||
+2
-2
@@ -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
@@ -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
@@ -12,25 +12,25 @@ constexpr unsigned sync_complete = 0x9117, already_signaled = 0x911A, condition_
|
||||
}
|
||||
|
||||
struct GlFunctions {
|
||||
void (JELLYFRAME_GL_CALL *gen_textures)(int, unsigned *) = nullptr;
|
||||
void (JELLYFRAME_GL_CALL *delete_textures)(int, const unsigned *) = nullptr;
|
||||
void (JELLYFRAME_GL_CALL *bind_texture)(unsigned, unsigned) = nullptr;
|
||||
void (JELLYFRAME_GL_CALL *texture_parameter)(unsigned, unsigned, int) = nullptr;
|
||||
void (JELLYFRAME_GL_CALL *texture_image)(unsigned, int, int, int, int, int, unsigned, unsigned, const void *) = nullptr;
|
||||
void (JELLYFRAME_GL_CALL *gen_framebuffers)(int, unsigned *) = nullptr;
|
||||
void (JELLYFRAME_GL_CALL *delete_framebuffers)(int, const unsigned *) = nullptr;
|
||||
void (JELLYFRAME_GL_CALL *bind_framebuffer)(unsigned, unsigned) = nullptr;
|
||||
void (JELLYFRAME_GL_CALL *attach_texture)(unsigned, unsigned, unsigned, unsigned, int) = nullptr;
|
||||
unsigned (JELLYFRAME_GL_CALL *check_framebuffer)(unsigned) = nullptr;
|
||||
void (JELLYFRAME_GL_CALL *blit)(int, int, int, int, int, int, int, int, unsigned, unsigned) = nullptr;
|
||||
void *(JELLYFRAME_GL_CALL *fence)(unsigned, unsigned) = nullptr;
|
||||
unsigned (JELLYFRAME_GL_CALL *poll_fence)(void *, unsigned, unsigned long long) = nullptr;
|
||||
void (JELLYFRAME_GL_CALL *delete_fence)(void *) = nullptr;
|
||||
void (JELLYFRAME_GL_CALL *flush)() = nullptr;
|
||||
void (JELLYFRAME_GL_CALL *get_integer)(unsigned, int *) = nullptr;
|
||||
unsigned char (JELLYFRAME_GL_CALL *is_enabled)(unsigned) = nullptr;
|
||||
void (JELLYFRAME_GL_CALL *enable)(unsigned) = nullptr;
|
||||
void (JELLYFRAME_GL_CALL *disable)(unsigned) = nullptr;
|
||||
void (JELLYXR_GL_CALL *gen_textures)(int, unsigned *) = nullptr;
|
||||
void (JELLYXR_GL_CALL *delete_textures)(int, const unsigned *) = nullptr;
|
||||
void (JELLYXR_GL_CALL *bind_texture)(unsigned, unsigned) = nullptr;
|
||||
void (JELLYXR_GL_CALL *texture_parameter)(unsigned, unsigned, int) = nullptr;
|
||||
void (JELLYXR_GL_CALL *texture_image)(unsigned, int, int, int, int, int, unsigned, unsigned, const void *) = nullptr;
|
||||
void (JELLYXR_GL_CALL *gen_framebuffers)(int, unsigned *) = nullptr;
|
||||
void (JELLYXR_GL_CALL *delete_framebuffers)(int, const unsigned *) = nullptr;
|
||||
void (JELLYXR_GL_CALL *bind_framebuffer)(unsigned, unsigned) = nullptr;
|
||||
void (JELLYXR_GL_CALL *attach_texture)(unsigned, unsigned, unsigned, unsigned, int) = nullptr;
|
||||
unsigned (JELLYXR_GL_CALL *check_framebuffer)(unsigned) = nullptr;
|
||||
void (JELLYXR_GL_CALL *blit)(int, int, int, int, int, int, int, int, unsigned, unsigned) = nullptr;
|
||||
void *(JELLYXR_GL_CALL *fence)(unsigned, unsigned) = nullptr;
|
||||
unsigned (JELLYXR_GL_CALL *poll_fence)(void *, unsigned, unsigned long long) = nullptr;
|
||||
void (JELLYXR_GL_CALL *delete_fence)(void *) = nullptr;
|
||||
void (JELLYXR_GL_CALL *flush)() = nullptr;
|
||||
void (JELLYXR_GL_CALL *get_integer)(unsigned, int *) = nullptr;
|
||||
unsigned char (JELLYXR_GL_CALL *is_enabled)(unsigned) = nullptr;
|
||||
void (JELLYXR_GL_CALL *enable)(unsigned) = nullptr;
|
||||
void (JELLYXR_GL_CALL *disable)(unsigned) = nullptr;
|
||||
|
||||
bool load(const SharedGlContext &context);
|
||||
};
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
[configuration]
|
||||
entry_symbol = "jellyframe_mpv_init"
|
||||
compatibility_minimum = "4.5"
|
||||
reloadable = false
|
||||
|
||||
[libraries]
|
||||
windows.x86_64 = "res://addons/jellyframe_mpv/bin/jellyframe_mpv.dll"
|
||||
linux.x86_64 = "res://addons/jellyframe_mpv/bin/jellyframe_mpv.linux.x86_64.so"
|
||||
linux.arm64 = "res://addons/jellyframe_mpv/bin/jellyframe_mpv.linux.arm64.so"
|
||||
|
||||
[dependencies]
|
||||
windows.x86_64 = { "res://addons/jellyframe_mpv/bin/libmpv-2.dll": "", "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": "" }
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,3 +1,3 @@
|
||||
#pragma once
|
||||
|
||||
inline constexpr const char *JELLYFRAME_STREAM_USER_AGENT = "Jellyframe";
|
||||
inline constexpr const char *JELLYXR_STREAM_USER_AGENT = "jellyXR";
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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", ""))
|
||||
|
||||
@@ -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
@@ -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()
|
||||
@@ -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,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())
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
class_name SubtitleTrack
|
||||
extends Node
|
||||
|
||||
signal changed
|
||||
var cues: Array[Dictionary] = []
|
||||
var index := -1
|
||||
var delay_ms := 0
|
||||
var message := "Subtitles off"
|
||||
var request: HTTPRequest
|
||||
var generation := 0
|
||||
|
||||
static func supported(stream: Dictionary) -> bool:
|
||||
return stream.get("IsTextSubtitleStream", false) or str(stream.get("Codec", "")).to_lower() in ["srt", "subrip", "vtt", "webvtt", "ass", "ssa", "mov_text", "ttml"]
|
||||
|
||||
func clear() -> void:
|
||||
generation += 1
|
||||
if is_instance_valid(request):
|
||||
request.cancel_request()
|
||||
request.queue_free()
|
||||
request = null
|
||||
cues.clear()
|
||||
index = -1
|
||||
message = "Subtitles off"
|
||||
changed.emit()
|
||||
|
||||
func select(api: JellyfinClient, item_id: String, source_id: String, selected: int) -> void:
|
||||
clear()
|
||||
index = selected
|
||||
if selected < 0: return
|
||||
var current := generation
|
||||
message = "Loading subtitles..."
|
||||
changed.emit()
|
||||
request = HTTPRequest.new()
|
||||
request.timeout = 120.0
|
||||
request.body_size_limit = 16 * 1024 * 1024
|
||||
add_child(request)
|
||||
var path := "/Videos/%s/%s/Subtitles/%d/Stream.vtt?CopyTimestamps=true&AddVttTimeMap=false" % [item_id.uri_encode(), source_id.uri_encode(), selected]
|
||||
var error := request.request(api.server + path, PackedStringArray(["Authorization: " + api.authorization()]))
|
||||
if error != OK:
|
||||
message = "Could not request subtitles. Select the track to retry."
|
||||
request.queue_free()
|
||||
request = null
|
||||
changed.emit()
|
||||
return
|
||||
var response: Array = await request.request_completed
|
||||
if current != generation: return
|
||||
request.queue_free()
|
||||
request = null
|
||||
if response[0] != HTTPRequest.RESULT_SUCCESS or int(response[1]) != 200:
|
||||
message = "Could not load subtitles. Select the track to retry."
|
||||
else:
|
||||
cues = parse(response[3].get_string_from_utf8())
|
||||
message = "Subtitles ready" if not cues.is_empty() else "No readable subtitle cues were returned."
|
||||
changed.emit()
|
||||
|
||||
static func timestamp(value: String) -> int:
|
||||
var parts := value.replace(",", ".").split(":")
|
||||
if parts.size() not in [2, 3]: return -1
|
||||
var seconds := 0.0
|
||||
for part in parts:
|
||||
if not part.is_valid_float(): return -1
|
||||
seconds = seconds * 60.0 + float(part)
|
||||
return roundi(seconds * 1000.0)
|
||||
|
||||
static func parse(document: String) -> Array[Dictionary]:
|
||||
var result: Array[Dictionary] = []
|
||||
var tags := RegEx.new()
|
||||
tags.compile("<[^>]*>")
|
||||
for block in document.replace("\r", "").split("\n\n", false):
|
||||
var lines := block.strip_edges().split("\n")
|
||||
if lines[0].begins_with("NOTE") or lines[0] in ["STYLE", "REGION"]: continue
|
||||
for line_index in range(lines.size()):
|
||||
if not " --> " in lines[line_index]: continue
|
||||
var times := lines[line_index].split(" --> ")
|
||||
var start := timestamp(times[0].strip_edges())
|
||||
var end := timestamp(times[1].strip_edges().get_slice(" ", 0))
|
||||
if start < 0 or end <= start: break
|
||||
var text := tags.sub("\n".join(lines.slice(line_index + 1)), "", true)
|
||||
text = text.replace("<", "<").replace(">", ">").replace(" ", " ").replace(""", '"').replace("&", "&")
|
||||
result.append({"start": start, "end": end, "text": text})
|
||||
break
|
||||
result.sort_custom(func(a: Dictionary, b: Dictionary): return a.start < b.start)
|
||||
var latest_end := 0
|
||||
for cue in result:
|
||||
latest_end = maxi(latest_end, cue.end)
|
||||
cue["latest_end"] = latest_end
|
||||
return result
|
||||
|
||||
func text_at(position_ms: int) -> String:
|
||||
var time := position_ms - delay_ms
|
||||
var low := 0
|
||||
var high := cues.size()
|
||||
while low < high:
|
||||
var middle := (low + high) / 2
|
||||
if int(cues[middle].start) <= time: low = middle + 1
|
||||
else: high = middle
|
||||
var active := PackedStringArray()
|
||||
var cursor := low - 1
|
||||
while cursor >= 0 and int(cues[cursor].latest_end) > time:
|
||||
if int(cues[cursor].end) > time: active.insert(0, cues[cursor].text)
|
||||
cursor -= 1
|
||||
return "\n".join(active)
|
||||
@@ -0,0 +1 @@
|
||||
uid://nv45euhbfw57
|
||||
@@ -34,6 +34,14 @@ func position_ms() -> int:
|
||||
func duration_ms() -> int:
|
||||
return 0
|
||||
|
||||
func buffered_position_ms() -> int:
|
||||
if bridge == null: return -1
|
||||
if bridge.has_method("buffered_position_ms"): return bridge.buffered_position_ms()
|
||||
if bridge.has_method("playback_diagnostics"):
|
||||
var values: Dictionary = bridge.playback_diagnostics()
|
||||
if values.has("read_ahead_ms"): return position_ms() + int(values.read_ahead_ms)
|
||||
return -1
|
||||
|
||||
func is_buffering() -> bool:
|
||||
return bridge != null and bridge.has_method("is_buffering") and bridge.is_buffering()
|
||||
|
||||
|
||||
+71
-4
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
extends RefCounted
|
||||
|
||||
static func snapshot(session: PlaybackSession, video: VideoBackend, playback_size := Vector2i.ZERO) -> Dictionary:
|
||||
var stream: Dictionary = {}
|
||||
for candidate in session.source.get("MediaStreams", []):
|
||||
if candidate.get("Type") == "Video":
|
||||
stream = candidate
|
||||
break
|
||||
var diagnostics: Dictionary = {}
|
||||
if video.bridge != null and video.bridge.has_method("playback_diagnostics"):
|
||||
diagnostics = video.bridge.playback_diagnostics()
|
||||
var width := int(stream.get("Width", 0))
|
||||
var height := int(stream.get("Height", 0))
|
||||
var fps := float(stream.get("AverageFrameRate", stream.get("RealFrameRate", 0.0)))
|
||||
var bitrate := int(session.source.get("Bitrate", 0))
|
||||
return {
|
||||
"Playback resolution": "%d x %d" % [playback_size.x, playback_size.y] if playback_size.x > 0 and playback_size.y > 0 else "Not reported",
|
||||
"Playback": session.method,
|
||||
"Source video": "%d x %d / %.3f fps" % [width, height, fps] if width > 0 else "Not reported",
|
||||
"Source codec": str(stream.get("Codec", "Not reported")).to_upper(),
|
||||
"Source bitrate": "%.2f Mbps" % (bitrate / 1000000.0) if bitrate > 0 else "Not reported",
|
||||
"Renderer / app FPS": "%s / %d" % [RenderingServer.get_current_rendering_method(), Engine.get_frames_per_second()],
|
||||
"Player state": "Buffering" if video.is_buffering() else ("Paused" if video.paused else "Playing"),
|
||||
"Video decoder": str(diagnostics.get("codec", "Platform decoder")),
|
||||
"Hardware decoding": str(diagnostics.get("hardware_device", "Not reported")),
|
||||
"Dropped frames": str(diagnostics.get("dropped_frames", "Not reported")),
|
||||
"Read ahead": "%.2f s" % (float(diagnostics.read_ahead_ms) / 1000.0) if diagnostics.has("read_ahead_ms") else "Not reported",
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://d146kg25o802p
|
||||
@@ -6,6 +6,7 @@ var slider: HSlider
|
||||
var elapsed_label: Label
|
||||
var duration_label: Label
|
||||
var dragging := false
|
||||
var buffered_bar: ProgressBar
|
||||
|
||||
func _ready() -> void:
|
||||
var times := HBoxContainer.new()
|
||||
@@ -13,13 +14,26 @@ func _ready() -> void:
|
||||
elapsed_label = Widgets.label("0:00", times, 20)
|
||||
elapsed_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
duration_label = Widgets.label("--:--", times, 20)
|
||||
var track_area := Control.new()
|
||||
track_area.custom_minimum_size.y = 56
|
||||
add_child(track_area)
|
||||
buffered_bar = ProgressBar.new()
|
||||
buffered_bar.show_percentage = false
|
||||
buffered_bar.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
buffered_bar.set_anchors_and_offsets_preset(Control.PRESET_TOP_WIDE)
|
||||
buffered_bar.offset_top = 23
|
||||
buffered_bar.offset_bottom = 33
|
||||
buffered_bar.add_theme_stylebox_override("background", Widgets.surface(Color("445268"), 5))
|
||||
buffered_bar.add_theme_stylebox_override("fill", Widgets.surface(Color("8998af"), 5))
|
||||
track_area.add_child(buffered_bar)
|
||||
slider = HSlider.new()
|
||||
slider.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
slider.custom_minimum_size.y = 56
|
||||
slider.step = 1000
|
||||
slider.scrollable = false
|
||||
slider.editable = false
|
||||
slider.tooltip_text = "Seek to a time"
|
||||
var track := Widgets.surface(Color("445268"), 5)
|
||||
var track := Widgets.surface(Color.TRANSPARENT, 5)
|
||||
track.content_margin_top = 5
|
||||
track.content_margin_bottom = 5
|
||||
slider.add_theme_stylebox_override("slider", track)
|
||||
@@ -27,7 +41,7 @@ func _ready() -> void:
|
||||
fill.bg_color = Color("6de3cf")
|
||||
slider.add_theme_stylebox_override("grabber_area", fill)
|
||||
slider.add_theme_stylebox_override("grabber_area_highlight", fill)
|
||||
add_child(slider)
|
||||
track_area.add_child(slider)
|
||||
slider.drag_started.connect(func(): dragging = true)
|
||||
slider.value_changed.connect(func(value: float):
|
||||
elapsed_label.text = format_time(int(value))
|
||||
@@ -48,6 +62,10 @@ func update_time(position_ms: int, duration_ms: int, ready: bool = true) -> void
|
||||
elapsed_label.text = format_time(maxi(0, position_ms))
|
||||
duration_label.text = format_time(duration_ms) if duration_ms > 0 else "--:--"
|
||||
|
||||
func update_buffer(buffered_ms: int, duration_ms: int, ready: bool) -> void:
|
||||
buffered_bar.max_value = maxi(1, duration_ms)
|
||||
buffered_bar.value = clampi(buffered_ms, 0, duration_ms) if ready and buffered_ms >= 0 and duration_ms > 0 else 0
|
||||
|
||||
static func format_time(milliseconds: int) -> String:
|
||||
var seconds := maxi(0, milliseconds / 1000)
|
||||
if seconds >= 3600: return "%d:%02d:%02d" % [seconds / 3600, (seconds / 60) % 60, seconds % 60]
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://rm4cllsb0evx
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dtigioofv3r0x
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://dfo47upix8tug
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://ba0gb32v1fdln
|
||||
@@ -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
@@ -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,4 +1,4 @@
|
||||
package nu.lizzy.jellyframe.video;
|
||||
package nu.lizzy.jellyxr.video;
|
||||
|
||||
public final class PlaybackWatchdogTest {
|
||||
public static void main(String[] args) {
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://do8m6no23n03q
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in new issue
Block a user