Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
766e67bffd | ||
|
|
8e1d97dc9b | ||
|
|
de3fe8fcf8 | ||
|
|
3d11ccbbf8 | ||
|
|
ec9821f1af | ||
|
|
f26405797e | ||
|
|
2f684e23a2 | ||
|
|
7aede92244 | ||
|
|
bce434a115 | ||
|
|
18a50d38ab | ||
|
|
c404131924 | ||
|
|
09a5f73393 | ||
|
|
dfc1664806 | ||
|
|
6c0bfd4e4a | ||
|
|
6bfec8bf19 | ||
|
|
1c18c2c328 | ||
|
|
c8e738ef21 | ||
|
|
7e5915e115 | ||
|
|
be5ea10875 | ||
|
|
ba662f848b | ||
|
|
aa896397fe | ||
|
|
7bf367419f | ||
|
|
83cf192d2d | ||
|
|
2a613ebed5 | ||
|
|
8454ab5de3 | ||
|
|
2c74923701 | ||
|
|
22ed9e1ede | ||
|
|
7987fffed4 | ||
|
|
858293663b | ||
|
|
6f9a9ac6b5 | ||
|
|
ee95d79efe | ||
|
|
e39fd5e77c | ||
|
|
47836de7d8 | ||
|
|
3164a84545 |
No files matched your search
@@ -0,0 +1,10 @@
|
||||
# Force LF line endings for scripts and workflows so Linux/SteamOS execution never breaks
|
||||
*.sh text eol=lf
|
||||
*.py text eol=lf
|
||||
*.yml text eol=lf
|
||||
*.desktop text eol=lf
|
||||
*.bat text eol=crlf
|
||||
*.spv binary
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
@@ -0,0 +1,88 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Existing v* tag to publish (leave empty for a build-only run)"
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Steam Frame ARM64 release
|
||||
# Native aarch64 runner: no cross-toolchain or device sysroot needed.
|
||||
# Debian 13 (trixie) ships FFmpeg 7, matching the SONAMEs present on the
|
||||
# Steam Frame (libavcodec.so.61, libavutil.so.59, ...).
|
||||
runs-on: ubuntu-24.04-arm
|
||||
container: debian:trixie
|
||||
steps:
|
||||
- name: Install build dependencies
|
||||
run: |
|
||||
apt-get update
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
build-essential cmake ninja-build git ca-certificates pkg-config file \
|
||||
glslang-tools libvulkan-dev \
|
||||
libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libswresample-dev \
|
||||
libpulse-dev libfreetype-dev libsmbclient-dev \
|
||||
libx11-dev libxrandr-dev libxxf86vm-dev libgl-dev
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.tag || github.ref }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Check scripts
|
||||
run: |
|
||||
set -eux
|
||||
sh -n tools/install.sh
|
||||
bash -n tools/package-release.sh
|
||||
bash -n tools/build.sh
|
||||
|
||||
- name: Build
|
||||
run: bash tools/build.sh
|
||||
|
||||
- name: Package
|
||||
run: |
|
||||
set -eux
|
||||
tag="${{ inputs.tag || github.ref_name }}"
|
||||
bash tools/package-release.sh build pixelvr-linux-arm64.tar.gz "${tag}"
|
||||
cp pixelvr-linux-arm64.tar.gz "pixelvr-linux-arm64-${tag}.tar.gz"
|
||||
sha256sum pixelvr-linux-arm64.tar.gz > pixelvr-linux-arm64.tar.gz.sha256
|
||||
sha256sum "pixelvr-linux-arm64-${tag}.tar.gz" > "pixelvr-linux-arm64-${tag}.tar.gz.sha256"
|
||||
|
||||
- name: Verify artifact
|
||||
run: |
|
||||
file build/bin/pixelvr
|
||||
file build/bin/pixelvr | grep -q 'ARM aarch64'
|
||||
tar -tzf pixelvr-linux-arm64.tar.gz | grep -q '^PixelVRPlayer/run.sh$'
|
||||
tar -tzf pixelvr-linux-arm64.tar.gz | grep -q '^PixelVRPlayer/install.sh$'
|
||||
tar -tzf pixelvr-linux-arm64.tar.gz | grep -q '^PixelVRPlayer/VERSION$'
|
||||
tar -tzf pixelvr-linux-arm64.tar.gz | grep -q '/shaders/thumb.frag.spv$'
|
||||
sha256sum -c pixelvr-linux-arm64.tar.gz.sha256
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: pixelvr-linux-arm64
|
||||
path: |
|
||||
pixelvr-linux-arm64.tar.gz
|
||||
pixelvr-linux-arm64.tar.gz.sha256
|
||||
pixelvr-linux-arm64-v*.tar.gz
|
||||
|
||||
- name: Publish GitHub release
|
||||
if: startsWith(github.ref, 'refs/tags/v') || inputs.tag != ''
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ inputs.tag || github.ref_name }}
|
||||
generate_release_notes: true
|
||||
files: |
|
||||
pixelvr-linux-arm64.tar.gz
|
||||
pixelvr-linux-arm64.tar.gz.sha256
|
||||
pixelvr-linux-arm64-v*.tar.gz
|
||||
pixelvr-linux-arm64-v*.tar.gz.sha256
|
||||
@@ -5,6 +5,8 @@
|
||||
/install/
|
||||
/dist/
|
||||
/sysroot/
|
||||
# Release artifacts are attached to GitHub releases, not committed
|
||||
/*.tar.gz
|
||||
*.o
|
||||
*.obj
|
||||
*.a
|
||||
@@ -40,3 +42,8 @@ Thumbs.db
|
||||
# Local secrets / device config
|
||||
/local.env
|
||||
/secrets/
|
||||
/scratch/
|
||||
/test_vault
|
||||
/*-plan.md
|
||||
/test_*.cpp
|
||||
/protected-*.md
|
||||
@@ -108,7 +108,8 @@ set(PIXELVR_SHADERS
|
||||
shaders/ui.vert
|
||||
shaders/ui.frag
|
||||
shaders/text.vert
|
||||
shaders/text.frag)
|
||||
shaders/text.frag
|
||||
shaders/thumb.frag)
|
||||
pixelvr_compile_shaders(pixelvr_shaders
|
||||
OUTPUT_DIR "${CMAKE_BINARY_DIR}/shaders"
|
||||
SOURCES ${PIXELVR_SHADERS})
|
||||
@@ -121,13 +122,18 @@ add_library(pixelvr_core STATIC
|
||||
src/xr/xr_result.cpp
|
||||
src/xr/xr_context.cpp
|
||||
src/xr/render_model.cpp
|
||||
src/xr/vr_keyboard.cpp
|
||||
src/net/smb_credentials.cpp
|
||||
src/gfx/vulkan_renderer.cpp
|
||||
src/gfx/gltf_mesh.cpp
|
||||
src/ui/settings_menu.cpp
|
||||
src/ui/thumbnail_cache.cpp
|
||||
src/app/vault_manager.cpp
|
||||
src/app/app.cpp)
|
||||
|
||||
target_include_directories(pixelvr_core PUBLIC
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/src")
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/src"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/third_party/openvr")
|
||||
target_include_directories(pixelvr_core PRIVATE "${cgltf_SOURCE_DIR}" "${stb_SOURCE_DIR}")
|
||||
|
||||
target_compile_definitions(pixelvr_core PUBLIC
|
||||
@@ -138,7 +144,8 @@ target_compile_definitions(pixelvr_core PUBLIC
|
||||
target_link_libraries(pixelvr_core PUBLIC
|
||||
OpenXR::openxr_loader
|
||||
Vulkan::Vulkan
|
||||
Threads::Threads)
|
||||
Threads::Threads
|
||||
${CMAKE_DL_LIBS})
|
||||
|
||||
add_dependencies(pixelvr_core pixelvr_shaders)
|
||||
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
<p align="center">
|
||||
<img src="assets/hero.png" width="100%" alt="Pixel VR Player" />
|
||||
</p>
|
||||
|
||||
# Pixel VR Player
|
||||
|
||||
A native VR video player for the **Valve Steam Frame**, playing flat 2D, 180°, and
|
||||
@@ -13,26 +17,81 @@ A native VR video player for the **Valve Steam Frame**, playing flat 2D, 180°,
|
||||
> NV12) with zero-copy dma-buf import; flat / 180° / 360° projection and mono /
|
||||
> SBS (including half-SBS) / TB stereo, auto-detected from the filename; spatial
|
||||
> audio with a master clock. In-headset UI: file browser (with file type and
|
||||
> size), transport bar, settings menu, and a downloads manager. Native Valve
|
||||
> size), transport bar, settings menu, downloads manager, bookmarks overview,
|
||||
> protected SMB network share manager with virtual keyboard laser auto-focus,
|
||||
> and an encrypted hidden vault. Native Valve
|
||||
> Steam Frame controller support (`XR_VALVE_frame_controller_interaction`) using
|
||||
> the runtime's own render model, with per-button animation on the model and
|
||||
> press-to-bind remapping.
|
||||
|
||||
## Install (prebuilt release)
|
||||
## Screenshots
|
||||
|
||||
Download `pixelvr-linux-arm64.tar.gz` from the
|
||||
[Releases](../../releases) page and copy it to your Steam Frame, then:
|
||||
<p align="center">
|
||||
<img src="docs/ui-redesign/mockup-unified-1-files.jpg" width="49%" alt="Spatial File Browser" />
|
||||
<img src="docs/ui-redesign/mockup-unified-2-downloads.jpg" width="49%" alt="Downloads Manager" />
|
||||
</p>
|
||||
<p align="center">
|
||||
<img src="docs/ui-redesign/mockup-unified-3-settings.jpg" width="49%" alt="Settings Panel" />
|
||||
<img src="docs/ui-redesign/mockup-unified-4-cinema-hud.jpg" width="49%" alt="Cinema Playback HUD" />
|
||||
</p>
|
||||
|
||||
## Installation
|
||||
|
||||
Choose whichever method fits your setup:
|
||||
|
||||
### Option A: USB Drive / MicroSD Card (No typing & No Dev Mode needed)
|
||||
|
||||
The simplest method for standard users without touching the terminal:
|
||||
|
||||
1. Download `pixelvr-linux-arm64.tar.gz` from the [Releases](https://github.com/Pixelplanet/Pixel-VR-Player/releases) page onto a USB flash drive or MicroSD card.
|
||||
2. Plug it into your Steam Frame and switch to **Desktop Mode**.
|
||||
3. Open the USB drive in Dolphin and double-click **`Install Pixel VR Player.desktop`**.
|
||||
4. A confirmation dialog will appear when finished. Return to Gaming Mode and launch it directly from your Steam Library!
|
||||
|
||||
### Option B: One command in headset terminal
|
||||
|
||||
If you are already in Desktop Mode on the Steam Frame, open **Konsole** and paste:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Pixelplanet/Pixel-VR-Player/main/tools/install.sh | sh
|
||||
```
|
||||
|
||||
*(No root access or disabled read-only filesystem needed; it installs entirely into user space).*
|
||||
|
||||
### Option C: From your PC over Wi-Fi (Requires SSH or Dev Mode)
|
||||
|
||||
If you have SSH enabled or Developer Mode turned on with your headset paired:
|
||||
|
||||
- **Windows:** Double-click [`tools/install-from-pc.bat`](tools/install-from-pc.bat) and enter your headset's IP.
|
||||
- **macOS / Linux:** Run `./tools/install-from-pc.sh`.
|
||||
|
||||
The script connects over SSH, installs the app, and registers the Steam shortcut remotely.
|
||||
|
||||
Launch **Pixel VR Player** directly from your Steam library, or from terminal:
|
||||
|
||||
```bash
|
||||
tar -xzf pixelvr-linux-arm64.tar.gz -C ~
|
||||
chmod +x ~/PixelVRPlayer/run.sh
|
||||
~/PixelVRPlayer/run.sh # opens the in-headset file browser
|
||||
~/PixelVRPlayer/run.sh /path/clip.mp4 # …or auto-play a specific clip
|
||||
```
|
||||
|
||||
To launch it from the headset library, add `~/PixelVRPlayer/run.sh` as a
|
||||
**Non-Steam Game** (Steam ▸ Add a Non-Steam Game). SteamVR must be running; the
|
||||
app logs to `~/pixelvr-run.log`.
|
||||
Installer options:
|
||||
|
||||
| Option | Effect |
|
||||
| --- | --- |
|
||||
| `--dir DIR` | install somewhere other than `~/PixelVRPlayer` |
|
||||
| `--version vX.Y.Z` | install a specific release instead of the latest |
|
||||
| `--no-steam` | skip the Steam library entry |
|
||||
| `--restart-steam` | close Steam first, then reopen it so the entry appears at once |
|
||||
| `--uninstall` | remove the app and its Steam shortcut |
|
||||
|
||||
Re-run the same command to upgrade. Settings (`~/.config/pixelvr`), cached
|
||||
thumbnails (`~/.cache/pixelvr`), and hidden vault media (`~/.pixelvr_vault`) live
|
||||
outside the install directory and are kept intact.
|
||||
|
||||
**Offline install:** download `pixelvr-linux-arm64.tar.gz` from the
|
||||
[Releases](https://github.com/Pixelplanet/Pixel-VR-Player/releases) page, copy it to the headset, extract it and run
|
||||
`./install.sh` from the extracted folder — or use `run.sh` directly. The app logs
|
||||
to `~/pixelvr-run.log`.
|
||||
|
||||
Requirements: the SteamVR OpenXR runtime (SteamOS), Vulkan, and FFmpeg with the
|
||||
V4L2 M2M decoders. For zero-copy dma-buf decoding, build the patched FFmpeg into
|
||||
@@ -40,6 +99,149 @@ V4L2 M2M decoders. For zero-copy dma-buf decoding, build the patched FFmpeg into
|
||||
back to the system FFmpeg with CPU colour conversion.
|
||||
|
||||
|
||||
## Updating
|
||||
|
||||
Updating Pixel VR Player is seamless, fast, and 100% non-destructive.
|
||||
|
||||
> [!NOTE]
|
||||
> **Your data is always preserved!**
|
||||
> All user preferences (`~/.config/pixelvr/config.json`), saved SMB network shares and credentials (`~/.config/pixelvr/smb_shares.json`), quick bookmarks (`~/.config/pixelvr/bookmarks.json`), cached video thumbnails (`~/.cache/pixelvr`), and encrypted media in the Discreet Vault (`~/.pixelvr_vault`) are stored outside the application folder and remain completely untouched across updates.
|
||||
|
||||
Choose your preferred update method:
|
||||
|
||||
### Method 1: 1-Click Desktop Update (Easiest on Headset)
|
||||
|
||||
No terminal or typing required:
|
||||
1. Switch your Steam Frame to **Desktop Mode** (Power menu in SteamVR or SteamOS).
|
||||
2. Double-click the **`Update Pixel VR Player`** icon on your Desktop.
|
||||
3. A popup will notify you that the update has started. The updater verifies SHA-256 checksums and updates the app in-place.
|
||||
4. When finished, a completion notification will appear. Switch back to Gaming Mode and launch the player!
|
||||
|
||||
*(Alternatively, open Dolphin file manager, navigate to `~/PixelVRPlayer`, and double-click `update.sh`)*.
|
||||
|
||||
### Method 2: 1-Click Update from your PC over Wi-Fi
|
||||
|
||||
Update your headset remotely over your local network:
|
||||
- **Windows:** Double-click [`tools/update-from-pc.bat`](tools/update-from-pc.bat), enter your Steam Frame's IP address, and press Enter.
|
||||
- **macOS / Linux:** Run `./tools/update-from-pc.sh <headset-ip>`.
|
||||
|
||||
### Method 3: One Command in Headset Terminal (Konsole / SSH)
|
||||
|
||||
Open **Konsole** on the headset or connect via SSH, then run:
|
||||
|
||||
```bash
|
||||
~/PixelVRPlayer/update.sh
|
||||
```
|
||||
|
||||
Or re-run the web installer command:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Pixelplanet/Pixel-VR-Player/main/tools/install.sh | sh
|
||||
```
|
||||
|
||||
### Method 4: USB Drive / MicroSD Card (Offline)
|
||||
|
||||
1. Download `pixelvr-linux-arm64.tar.gz` from the [Releases](https://github.com/Pixelplanet/Pixel-VR-Player/releases) page to a USB flash drive or MicroSD card.
|
||||
2. Plug it into your Steam Frame and open it in Desktop Mode.
|
||||
3. Double-click **`Install Pixel VR Player.desktop`** (or run `./install.sh --update` from the unpacked folder).
|
||||
|
||||
|
||||
## Uninstallation
|
||||
|
||||
To remove Pixel VR Player and its Steam shortcut from your Steam Frame:
|
||||
|
||||
### From the Headset (Desktop Mode / Konsole or SSH)
|
||||
|
||||
Run the uninstaller script directly:
|
||||
|
||||
```bash
|
||||
~/PixelVRPlayer/install.sh --uninstall
|
||||
```
|
||||
|
||||
Or run the one-command uninstaller:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Pixelplanet/Pixel-VR-Player/main/tools/install.sh | sh -s -- --uninstall
|
||||
```
|
||||
|
||||
This cleanly removes:
|
||||
* The application folder (`~/PixelVRPlayer`)
|
||||
* The Non-Steam Game shortcut from Steam's library (`shortcuts.vdf`)
|
||||
* All custom Steam grid artwork from `~/.steam/steam/userdata/*/config/grid/`
|
||||
|
||||
### From your PC over Wi-Fi
|
||||
|
||||
- **Windows:** Double-click [`tools/uninstall-from-pc.bat`](tools/uninstall-from-pc.bat) and enter your headset's IP.
|
||||
- **macOS / Linux:** Run `./tools/uninstall-from-pc.sh`.
|
||||
|
||||
### Complete Data Purge (Optional)
|
||||
|
||||
By default, user settings (`~/.config/pixelvr`), cached video thumbnails (`~/.cache/pixelvr`), and hidden vault media (`~/.pixelvr_vault`) are kept intact so upgrading or reinstalling never loses your library state.
|
||||
|
||||
To completely erase all application data and settings from the headset, run:
|
||||
|
||||
```bash
|
||||
rm -rf ~/.config/pixelvr ~/.cache/pixelvr ~/.pixelvr_vault
|
||||
```
|
||||
|
||||
|
||||
## Controls
|
||||
|
||||
The laser pointer and the control bar are **separate** input paths, so the bar
|
||||
stays usable while a panel is open.
|
||||
|
||||
| Input | Action |
|
||||
| --- | --- |
|
||||
| **Trigger** | Point and click: open the entry under the laser, or press a control-bar button |
|
||||
| **A** | Open the highlighted entry — folder navigates, video plays / streams. Outside the browser it is play / pause |
|
||||
| **X** / **Y** | Download the highlighted entry (folders confirm first, showing their size) |
|
||||
| **B** | Open or close the file browser; also closes a panel and cancels the download dialog |
|
||||
| **Grip** | Toggle **that hand's** laser pointer on/off. Turn one off to navigate with the thumbstick or d-pad without the beam interfering, and to choose which controller points |
|
||||
| Thumbstick | Move the highlight in a panel; left/right seeks while playing |
|
||||
| **Bumper** | Recenter the view in front of you |
|
||||
|
||||
The control bar has a **Download** button (arrow) that downloads the entry
|
||||
highlighted in the browser, and a **Downloads** button (list icon) that opens the
|
||||
downloads manager — where you can pause, resume, stop and clear jobs, then play a
|
||||
finished file or open a finished folder. Opening the downloads manager replaces
|
||||
the file browser rather than stacking on top of it.
|
||||
|
||||
All button bindings can be changed in **Settings ▸ Controls** (press-to-bind).
|
||||
|
||||
|
||||
## Discreet Media Vault
|
||||
|
||||
Pixel VR Player includes a private, zero-trace local storage vault (`~/.pixelvr_vault`) hidden from the standard file browser and startup directory scans.
|
||||
|
||||
<details>
|
||||
<summary>🔒 <b>Accessing the Hidden Vault</b> (click to expand)</summary>
|
||||
|
||||
To open or re-lock the vault in VR without bringing up an on-screen keyboard or visible PIN dialog:
|
||||
|
||||
1. Fully squeeze either controller's **grip** (hold down the mechanical **Full Press** click).
|
||||
2. While holding the grip clicked, enter the 6-key factory combination:
|
||||
|
||||
> 🎵 *The Swedish pop sensation that won Eurovision in 1974 with "Waterloo"...*
|
||||
> 📐 *...followed by the two classic axes of the 2D Cartesian plane.*
|
||||
|
||||
<details>
|
||||
<summary>👉 <i>Need the direct button sequence? Click here to reveal</i></summary>
|
||||
|
||||
Hold **Full Grip Click** and press:
|
||||
> **`A` → `B` → `B` → `A` → `X` → `Y`**
|
||||
*(Within 2.5 seconds per step. Haptic feedback confirms each input).*
|
||||
|
||||
</details>
|
||||
|
||||
- **Instant Auto-Lock:** Re-locks automatically and immediately purges in-memory textures when the headset proximity sensor detects removal, or when clicking **[ 🔒 Lock Vault ]** in the sidebar.
|
||||
- **Custom Combinations:** You can record your own custom button sequence anytime in **Settings ▸ Vault & Privacy**.
|
||||
- **Move to Vault:** While unlocked, local videos can be moved directly into the vault from the browser.
|
||||
- **Safe Persistence:** Media inside `~/.pixelvr_vault` is never deleted when updating or uninstalling the app.
|
||||
|
||||
For full architecture and security details, see the [Hidden Vault Technical Specification](docs/HIDDEN_VAULT_SPEC.md).
|
||||
</details>
|
||||
|
||||
|
||||
## Repository layout
|
||||
|
||||
```
|
||||
@@ -123,6 +325,15 @@ aarch64 cross (for the Steam Frame):
|
||||
./tools/build.sh --aarch64 # configures + builds build-aarch64/
|
||||
```
|
||||
|
||||
Release builds are automated: `.github/workflows/release.yml` builds natively on
|
||||
an ARM64 runner in a Debian 13 container (FFmpeg 7, matching the headset's
|
||||
SONAMEs) and attaches `pixelvr-linux-arm64.tar.gz` to each `v*` tag's release.
|
||||
To package a local build by hand:
|
||||
|
||||
```bash
|
||||
./tools/package-release.sh build-aarch64 pixelvr-linux-arm64.tar.gz
|
||||
```
|
||||
|
||||
## Deploy to Steam Frame
|
||||
|
||||
See the full walkthrough in
|
||||
@@ -140,8 +351,7 @@ choice, and troubleshooting). In short, after a one-time Developer-Mode pairing:
|
||||
# override the target with --host steamos@<ip> or FRAME_HOST
|
||||
```
|
||||
|
||||
Either way the app appears in **Steam ▸ Library ▸ Non-Steam** as *Devkit Game:
|
||||
PixelVRPlayer*. To run it manually over SSH for debugging:
|
||||
Either way the app appears in **Steam ▸ Library ▸ Non-Steam** as **Pixel VR Player**. To run it manually over SSH for debugging:
|
||||
|
||||
```bash
|
||||
ssh steamos@frame '~/devkit-game/PixelVRPlayer/launch.sh /path/to/video.mp4'
|
||||
|
||||
@@ -7,19 +7,105 @@
|
||||
|
||||
---
|
||||
|
||||
## Active backlog (2026-10-14)
|
||||
## Active backlog (2026-10-20 — Completed)
|
||||
|
||||
- [x] **Virtual Keyboard Tilt Inversion, Raycast Auto-Focus & UI Hide Refinements**
|
||||
- **Symptom 1:** Keyboard spawned tilted with the top edge pitched forward (slanted away from user).
|
||||
- **Resolution 1:** Corrected tilt angle to \-0.38f\ (-21.8 degrees). The top edge slants backward away from user; face normal tilts upward directly toward the user's eyes.
|
||||
- **Symptom 2:** Laser beam shot 2.5m past the keyboard with no cursor dot on the keys; laser wasn't focused.
|
||||
- **Resolution 2:** Added keyboard plane collision test in \VulkanRenderer::updatePointer()\ using \KeyboardModalData::transform\. Laser ray terminates directly on the keyboard surface with glowing cursor dot on hovered keys. Also ensured active controller laser turns on automatically when keyboard opens, and guarded background menus from eating keyboard trigger clicks.
|
||||
- **Symptom 3:** Trigger pull while HUD dismissed didn't reactivate the UI unless hitting a hidden button; empty-space clicks re-dismissed the UI in the same frame.
|
||||
- **Resolution 3:** Recorded \wasHudDismissed\ at frame start and guarded dock interactions with \!wasHudDismissed\. Trigger click cleanly un-hides the HUD without clicking hidden buttons or re-dismissing in empty space.
|
||||
- **Symptom 4:** Analog grip squeeze was overly sensitive and accidentally reactivated the dismissed UI.
|
||||
- **Resolution 4:** Ignored soft analog grip (\in.gripDown\ and \BtnGrip\); only digital full grip click stop (\in.gripFullPressDown\) un-hides the HUD.
|
||||
|
||||
## Active backlog (2026-10-19 — Completed)
|
||||
|
||||
- [x] **1. Fix Virtual Keyboard Laser Pointer Focus & Tracking**
|
||||
- **Symptom:** Virtual keyboard opened, but laser pointer disappeared or wasn't focused on the keyboard; user had to squeeze/press the controller grip fully to activate it. Also, the keyboard centered where the Steam menu was centered when the app launched instead of where the user centered their view.
|
||||
- **Root Cause:**
|
||||
- SteamVR's virtual keyboard overlay key is `VRKeyboard_uid0` (from CEF `webhelper`), not `valve.steam.gamepadui.keyboard`. Previous lookups failed, so `MakeOverlaysInteractiveIfVisible` and `VROverlayInputMethod_Mouse` were never applied to the actual keyboard overlay.
|
||||
- `VulkanRenderer::updatePointer()` was returning early (`if (keyboardModal_.visible) return;`), which prevented raycasts to the modal plane and left `pointerActive_` false with no cursor dot.
|
||||
- `SetKeyboardTransformAbsolute` was never called with the user's current centered view orientation/position, so SteamVR defaulted to the launch standing origin.
|
||||
- **Resolution:**
|
||||
- Implemented dynamic overlay discovery across handles 1..128 and known keys (`VRKeyboard*`, `valve.steam.gamepadui.keyboard`, `system.keyboard`).
|
||||
- Configured `MakeOverlaysInteractiveIfVisible`, `VROverlayInputMethod_Mouse`, and `SendVRDiscreteScrollEvents` on the keyboard overlay.
|
||||
- Calculated world matrix in `TrackingUniverseStanding` directly from the user's HMD pose (or OpenXR `headPos` and `headForward`): placed 1.05m in front of eyes, 0.38m below eye level, tilted 28° up toward the eyes.
|
||||
- Applied transform via `SetKeyboardTransformAbsolute(vr::TrackingUniverseStanding, &kbMat)` and `SetOverlayTransformAbsolute`.
|
||||
- Updated `VulkanRenderer::updatePointer()` to raycast against the modal plane when `keyboardModal_.visible` is active, ensuring sharp laser pointer focus and a visible cursor dot.
|
||||
- Guarded `VrKeyboard::shutdown()` to prevent crash on exit.
|
||||
|
||||
- [x] **2. Controller Movement UI Dismissal Fix**
|
||||
- **Symptom:** When the user clicked "Hide UI" or clicked outside controls, any hand movement immediately reactivated the UI.
|
||||
- **Requirement:** Once dismissed, the UI must stay hidden regardless of controller movement/rotation, and only reactivate when the user explicitly presses a physical button on the controller.
|
||||
- **Resolution:**
|
||||
- In `App::run()`, when `hudDismissed_` is true, controller movement (`moveDist`) and rotation (`rotDiff`) are blocked from clearing `hudDismissed_` or setting `activity = true`.
|
||||
- Only physical button presses (`in.togglePlay`, `in.browserToggle`, `in.recenter`, `in.buttonDown[...]`, `in.gripDown`, `in.gripFullPressDown`, `in.select` outside cooldown, or thumbstick movement) reactivate the HUD.
|
||||
|
||||
- [x] **2. Strict IPv4 Validation & Live Sanitization**
|
||||
- **Symptom:** Host field accepted invalid characters, spaces, and did not strip leading zeros.
|
||||
- **Resolution:**
|
||||
- Created `src/util/ipv4.hpp` with `sanitizeIpv4()` and `isValidIpv4()`.
|
||||
- Live typing sanitization: automatically strips non-digits/dots, discards duplicate dots, clamps octets to 255, drops leading zeros in each octet (e.g. `001` -> `1`, `010` -> `10`, `00` -> `0`), limits to 4 octets, and preserves trailing dots while typing.
|
||||
- Added automated unit test suite `tests/test_ipv4.cpp` and verified all test cases pass on the device.
|
||||
- Verified strict `isValidIpv4()` validation before saving an SMB share in `saveSmbModal()`.
|
||||
|
||||
- [x] **3. Fix `???` Glyphs (Unicode Star Elimination)**
|
||||
- **Symptom:** Sidebar and header button displayed `???` instead of the star icon.
|
||||
- **Root Cause:** FreeType SDF text atlas was initialized only for ASCII codepoints 32..127. UTF-8 multi-byte sequence `★` (`\xe2\x98\x85`) produced 3 replacement `?` characters.
|
||||
- **Resolution:** Replaced all UTF-8 star glyphs with clean ASCII strings: `"Bookmarks"`, `"[ Bookmarked ]"`, and `"[ + Bookmark ]"`. Adjusted header breadcrumb max width to `0.46f` to prevent collision with the header button.
|
||||
|
||||
- [x] **4. Rename Quick Path to Bookmarks & Implement Multi-Bookmark Overview (`bookmarks://`)**
|
||||
- **Requirement:** Rename Quick Path to Bookmarks, and have the Bookmarks sidebar tab open an overview showing all bookmarked folders and files in regular browser views.
|
||||
- **Resolution:**
|
||||
- Added `Bookmark` struct and multi-bookmark storage in `Browser` with persistent JSON serialization to `~/.config/pixelvr/bookmarks.json`.
|
||||
- Implemented `bookmarks://` virtual directory navigation displaying all bookmarked paths with full cards (BigCards, MiniCards) and CompactList support, thumbnails, and direct activation.
|
||||
- Sidebar subtab 4 navigates to `bookmarks://` with proper active tab highlighting.
|
||||
- Header button toggles bookmark for the current open directory (`[ + Bookmark ]` / `[ Bookmarked ]`).
|
||||
- Action Menu (Y button) provides `"Add to Bookmarks"` / `"Remove from Bookmarks"` / `"Go to Bookmarks"`, and in `bookmarks://` provides `"Open Bookmark"` / `"Remove Bookmark"`.
|
||||
- Updated Settings Menu shortcut label to `"Bookmarks Shortcut"`.
|
||||
|
||||
- [x] **5. Seamless 1-Click Update System & Streamlined Distribution**
|
||||
- **Requirement:** Provide easy, friction-free updating so users do not need to memorize terminal commands or worry about losing their settings.
|
||||
- **Resolution:**
|
||||
- Added standalone local updater `tools/update.sh` and `--update` flag to `tools/install.sh`.
|
||||
- Added KDE Plasma `Update Pixel VR Player.desktop` desktop shortcut deployed automatically to `~/Desktop/`.
|
||||
- Added remote Wi-Fi 1-click updaters for PC users (`tools/update-from-pc.bat` for Windows and `tools/update-from-pc.sh` for macOS/Linux).
|
||||
- Updated `tools/package-release.sh` to package updater scripts in the release tarball.
|
||||
- Updated `README.md` with comprehensive update instructions across all platforms.
|
||||
|
||||
- [x] **6. Fix UI Dismissal Flicker and Recessed Island Alignment**
|
||||
- **Symptom:** Clicking outside the controls or clicking the Hide UI button caused the UI to briefly flicker for 1 frame and immediately reappear. The Hide UI button was also visually misaligned (half outside the dark island).
|
||||
- **Root Cause:**
|
||||
- `in.selectHeld` and trigger activity handling in `App::tick()` continuously reset `hudDismissed_ = false` and set `activity = true` on subsequent frames of the trigger pull, fighting the dismissal call.
|
||||
- Controller activity anchors were not reset on dismissal, and there was no dismissal grace cooldown, causing slight hand tremor during trigger release to immediately re-trigger movement thresholds.
|
||||
- The center transport recessed island background in `VulkanRenderer` was rendered at `cx = -0.05f, hw = 0.30f`, clipping through the center of the Hide UI button at `+0.26f`.
|
||||
- **Resolution:**
|
||||
- Created `App::dismissHud()` which sets `hudDismissed_ = true`, records `hudDismissTime_`, zeroes out activity, hides the control bar and laser ray, and snaps controller activity anchors to their exact current positions.
|
||||
- Added a 500ms dismissal cooldown preventing accidental re-engagement from trigger release tremors.
|
||||
- Blocked `in.select` and `in.selectHeld` from clearing `hudDismissed_` while the HUD is dismissed.
|
||||
- Re-centered the transport recessed island background to `cx = 0.0f, hw = 0.34f`, creating perfect 34mm symmetrical padding for all 5 buttons (`Stop`, `SeekBack`, `PlayPause`, `SeekFwd`, `HideHud`).
|
||||
- Added hover tooltip pills for `[Hide UI]` and `[Stop]`.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Carried over & Completed
|
||||
|
||||
Carried over:
|
||||
- [ ] A. Full-pitch recenter (usable lying down) — reorient screen + controls + 360/180 to full head orientation, not just horizon yaw.
|
||||
- [ ] B. Real controller models (glTF) rendered with depth.
|
||||
- [ ] C. Dynamic controller button labels from the user's bindings.
|
||||
|
||||
New (2026-10-14):
|
||||
- [x] D. Flat SBS/TB: detect + a working flat-stereo render mode (split per eye).
|
||||
- [x] E. Settings split into sections.
|
||||
- [x] F. Settings: pick video stream, audio stream, and a default audio language.
|
||||
- [ ] G. Downloads view: active downloads + speed + ETA; keep running in background.
|
||||
- [x] H. Sharper menu text (SDF).
|
||||
Completed (2026-10-18):
|
||||
- [x] I. Laser auto-release when pressing Steam button (unfocused session gates controller pointer).
|
||||
- [x] J. Native SteamVR / SteamOS Virtual Keyboard integration (OpenVR GamepadTextInput via dlopen).
|
||||
- [x] K. Protected SMB credentials manager: hardware-keyed AES-GCM encryption, strict permissions, full CRUD.
|
||||
- [x] L. Settings > Network & SMB Shares: manual IP/hostname input, authentication settings, auto-listed in Network browser.
|
||||
- [x] M. UI Follow Mode: Mode 0 (Rock-solid World Lock default, zero drift/tilt) and Mode 1 (Smooth Follow).
|
||||
- [x] N. Player UI dismissal: `[ ✕ Hide ]` button, empty space click-outside dismissal, B/Y toggle, and micro-tremor filtering (>0.06m threshold).
|
||||
- [x] O. Quick Path Whirligig-style bookmarking header button + R3 controller shortcut button.
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 336 KiB |
|
After Width: | Height: | Size: 286 KiB |
|
After Width: | Height: | Size: 798 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 936 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 852 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 860 KiB |
|
After Width: | Height: | Size: 994 KiB |
|
After Width: | Height: | Size: 952 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 119 KiB |
@@ -0,0 +1,124 @@
|
||||
# PixelVR Hidden Vault: Technical Specification & Architecture
|
||||
|
||||
## 1. Overview & Core Objectives
|
||||
|
||||
The **Hidden Vault** provides a private, zero-trace media storage and viewing capability within PixelVR Player. It allows users to store and view sensitive or private videos without virtual keyboard input, PIN dialogs, or visible hints in the default user interface.
|
||||
|
||||
### Key Requirements
|
||||
* **Zero Keyboard Input:** Unlocks strictly through a physical controller combination.
|
||||
* **Invisible by Default:** The vault directory (`~/.pixelvr_vault/`) is excluded from all directory scans on startup and cold boot.
|
||||
* **Discrete Full Press Squeeze:** Squeeze has two separate functions: *Soft Squeeze* (analog) and *Full Press* (discrete click). The combo operates **exclusively** on Full Press.
|
||||
* **Re-Lock Control:** When unlocked, a dedicated `[ 🔒 Lock Vault ]` button appears in the sidebar. It is completely absent when locked.
|
||||
* **Move to Vault:** Downloaded/local media can be moved into the vault when unlocked, using a button binding configured in the Vault Settings tab.
|
||||
* **Strict Privacy Isolation:** Thumbnails and memory textures for vault media are purged immediately upon re-locking.
|
||||
|
||||
---
|
||||
|
||||
## 2. Discrete Grip Architecture: Soft Squeeze vs. Full Press
|
||||
|
||||
Modern VR controllers (Valve Frame, Valve Index, Oculus/Meta Touch) provide two separate functional signals for controller grip:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph "Controller Hardware"
|
||||
GripSensor["Grip Sensor"]
|
||||
end
|
||||
|
||||
subgraph "OpenXR Functional Pathways"
|
||||
GripSensor -->|"Gentle / Resting Hold"| SoftSqueeze["Soft Squeeze<br>(/input/squeeze/value)<br>Analog Float (0.0 .. 1.0)"]
|
||||
GripSensor -->|"Mechanical Click / Bottom-Out"| FullPress["Full Press<br>(/input/squeeze/click)<br>Discrete Boolean Click"]
|
||||
end
|
||||
|
||||
subgraph "PixelVR Input Dispatch"
|
||||
SoftSqueeze -->|"Used for Laser Toggle / Holding"| NormalInput["Standard Input Handling<br>(Ignored by Vault)"]
|
||||
FullPress -->|"Prerequisite for Combo"| ComboDetector["Vault Combo State Machine<br>(A-B-B-A-X-Y)"]
|
||||
end
|
||||
```
|
||||
|
||||
### Hardware Functional Separation
|
||||
1. **Soft Squeeze (`/input/squeeze/value`):**
|
||||
* Continuous analog range ($0.0 \dots 1.0$).
|
||||
* Triggered whenever fingers rest casually on the controller or squeeze lightly.
|
||||
* **Behavior:** Completely disregarded by the Vault combo detector. A user can squeeze or hold the controller at any pressure without accidentally arming the combo.
|
||||
2. **Full Press (`/input/squeeze/click` or `/input/grip/click`):**
|
||||
* Discrete boolean action representing the physical bottom-out or tactile click of the grip mechanism.
|
||||
* **Behavior:** Required as the continuous hold prerequisite for the combo state machine. The user must intentionally bottom out the grip to enter the code.
|
||||
|
||||
---
|
||||
|
||||
## 3. Unlock Sequence & Combo State Machine
|
||||
|
||||
### 3.1 Default Sequence
|
||||
$$\text{Grip Full Press (Held)} + [\text{BtnA} \rightarrow \text{BtnB} \rightarrow \text{BtnB} \rightarrow \text{BtnA} \rightarrow \text{BtnX} \rightarrow \text{BtnY}]$$
|
||||
|
||||
### 3.2 State Machine Rules
|
||||
* **Sliding Timeout:** 2.5 seconds between button presses. If no button is pressed within 2.5 seconds, or if the grip Full Press is released before completion, the buffer resets to empty.
|
||||
* **Wrong Button Penalty:** An incorrect button immediately clears the buffer.
|
||||
* **Input Interception:** While `gripFullPressHeld` is true, face button presses (`A`, `B`, `X`, `Y`) are consumed exclusively by the combo detector. They are **suppressed from normal UI actions** (play/pause, back/close, download), preventing accidental interaction with the background player while entering the code.
|
||||
* **Haptic Feedback:**
|
||||
* Valid sequence step: subtle 10ms click vibration.
|
||||
* Successful Unlock / Lock: distinct 60ms double pulse.
|
||||
|
||||
---
|
||||
|
||||
## 4. UI States & Spatial Navigation
|
||||
|
||||
### 4.1 State: LOCKED (Default State on Launch)
|
||||
* The vault directory `~/.pixelvr_vault/` is excluded from all directory enumerations in `Browser::scanLocalInto()`.
|
||||
* The sidebar navigation rail contains standard entries only (`Local Storage`, `Videos`, `SMB Shares`, `Downloads`).
|
||||
* The `[ 🔒 Lock Vault ]` button is completely omitted from the layout.
|
||||
* The Settings menu displays standard 4 tabs (`Display`, `Controls`, `Audio`, `System`).
|
||||
|
||||
### 4.2 State: UNLOCKED
|
||||
* **Navigation Rail:** A glowing `[ 🔒 Vault ]` shortcut appears under Library categories. Clicking it opens `~/.pixelvr_vault/` in the 3x2 media grid.
|
||||
* **Re-Lock Button:** A prominent `[ 🔒 Lock Vault ]` button appears in the sidebar.
|
||||
* **Settings:** A 5th tab appears: **`Vault & Privacy`**.
|
||||
* **Re-locking Actions:** Clicking the lock button (or repeating the combo):
|
||||
1. Stops any currently playing video if its path is within `~/.pixelvr_vault/` and calls `renderer_.clearVideo()`.
|
||||
2. Resets the file browser immediately to `Home` (`openHome()`).
|
||||
3. Purges all in-memory thumbnail cache entries and GPU card textures for vault files.
|
||||
4. Hides the `[ 🔒 Vault ]` shortcut, `[ 🔒 Lock Vault ]` button, and the `Vault & Privacy` settings tab.
|
||||
|
||||
---
|
||||
|
||||
## 5. "Move to Vault" File Management Engine
|
||||
|
||||
To allow seamless transfer of downloaded videos into the vault without using desktop file managers:
|
||||
|
||||
1. **Availability:** The move feature is active **only when the vault is unlocked** and **only for downloaded / local files** (SMB files cannot be moved directly; they must be downloaded first).
|
||||
2. **Dedicated Button Binding:** Configured inside the **Vault & Privacy** settings tab (e.g. `X`, `Y`, `Left Stick Click`, or `None`), independent of regular playback control bindings.
|
||||
3. **Robust Move Implementation:**
|
||||
* Primary: `std::filesystem::rename(src, dest)`.
|
||||
* Cross-device fallback: If `rename()` fails with `EXDEV` (e.g. moving from a MicroSD card or external drive on `/run/media/...` to internal storage `~/.pixelvr_vault/`), the engine automatically performs `std::filesystem::copy_file(src, dest)` followed by `std::filesystem::remove(src)`.
|
||||
4. **Collision Handling:** If `~/.pixelvr_vault/video.mp4` already exists, the destination is automatically renamed to `video_1.mp4`.
|
||||
5. **Cache Eviction:** Any public preview file previously generated in `~/.cache/pixelvr/thumbnails/` for the original path is deleted immediately upon moving.
|
||||
|
||||
---
|
||||
|
||||
## 6. Dynamic "Vault & Privacy" Settings Tab
|
||||
|
||||
When the vault is unlocked, a 5th tab becomes available in the Settings menu:
|
||||
|
||||
| Setting | Options | Default | Description |
|
||||
|---|---|---|---|
|
||||
| **Auto-Lock on Headset Off** | `[Enabled / Disabled]` | `Enabled` | Instantly locks the vault when headset proximity/focus is lost |
|
||||
| **Inactivity Auto-Lock** | `[Disabled, 2m, 5m, 10m]` | `5 min` | Automatically locks if no controller input is detected |
|
||||
| **Move to Vault Button** | `[X, Y, Stick Click, None]` | `Button X` | Physical button to move focused local file to vault |
|
||||
| **Custom Combo Sequence** | `[Configure / Record]` | `A B B A X Y` | Records a custom 6-button sequence |
|
||||
| **Lock Vault Now** | Action Button | — | Manually locks the vault immediately |
|
||||
|
||||
*Settings are persisted in `~/.config/pixelvr/pixelvr.conf` under the `[vault]` section.*
|
||||
|
||||
---
|
||||
|
||||
## 7. Edge Cases & Mitigation Matrix
|
||||
|
||||
| Edge Case | Failure Mode / Risk | Architectural Mitigation |
|
||||
|---|---|---|
|
||||
| **Accidental Soft Squeeze** | Resting fingers squeeze grip slightly and start recording combo inputs. | **Hardware Function Separation:** Only the discrete `Full Press` (`squeeze/click`) arms the combo. The analog `Soft Squeeze` (`squeeze/value`) is completely ignored. |
|
||||
| **Normal Button Contamination** | Pressing `A B B A X Y` pauses video or starts downloads. | Face button events are suppressed from the UI while `gripFullPressHeld` is true. |
|
||||
| **Cross-Filesystem Moves** | Moving a file from a MicroSD card to internal storage fails with `EXDEV`. | Automatic fallback from `rename()` to `copy_file()` + `remove()`. |
|
||||
| **Name Collisions** | Moving `clip.mp4` when `~/.pixelvr_vault/clip.mp4` already exists. | Target automatically becomes `clip_1.mp4` to prevent data loss. |
|
||||
| **Thumbnail Leakage** | Public thumbnail remains in shared cache after moving video to vault. | Old public thumbnail is deleted upon move; vault previews are stored only in `~/.pixelvr_vault/.thumbs/`. |
|
||||
| **Active Video on Lock** | Video from vault is playing when headset is removed or lock button is clicked. | Playback engine calls `media_.close()` and `renderer_.clearVideo()` immediately so no private frame lingers. |
|
||||
| **Headset Sleep / SteamVR Menu** | User presses SteamVR system button or takes off headset. | OpenXR session state loss (`XR_SESSION_STATE_VISIBLE` or `FOCUSED` lost) triggers immediate lock and resets to Home. |
|
||||
|
After Width: | Height: | Size: 759 KiB |
|
After Width: | Height: | Size: 602 KiB |
|
After Width: | Height: | Size: 800 KiB |
|
After Width: | Height: | Size: 784 KiB |
|
After Width: | Height: | Size: 788 KiB |
|
After Width: | Height: | Size: 798 KiB |
|
After Width: | Height: | Size: 812 KiB |
|
After Width: | Height: | Size: 336 KiB |
|
After Width: | Height: | Size: 286 KiB |
|
After Width: | Height: | Size: 936 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 852 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 540 KiB |
|
After Width: | Height: | Size: 961 KiB |
|
After Width: | Height: | Size: 642 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 445 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 436 KiB |
|
After Width: | Height: | Size: 186 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 455 KiB |
|
After Width: | Height: | Size: 860 KiB |
|
After Width: | Height: | Size: 183 KiB |
|
After Width: | Height: | Size: 994 KiB |
|
After Width: | Height: | Size: 952 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 119 KiB |
@@ -0,0 +1,161 @@
|
||||
# PixelVR Hidden Vault: Architectural Concept & Design Proposals
|
||||
|
||||
## 1. Executive Summary & Objective
|
||||
|
||||
The objective is to provide a frictionless, privacy-focused **Hidden Vault** feature in PixelVR Player for confidential/private media files without requiring a virtual keyboard or password prompt. Virtual keyboard entry in VR is notoriously cumbersome, immersion-breaking, and vulnerable to "shoulder surfing" (people watching the VR mirror window on a PC or TV screen).
|
||||
|
||||
### Core Requirements
|
||||
1. **Zero Keyboard Input:** Unlock mechanism must use physical VR controller interactions or spatial UI patterns.
|
||||
2. **Locked & Hidden by Default:** On app launch and cold restart, the folder and all its contents are completely invisible and unlisted.
|
||||
3. **Explicit Re-Lock Control:** Once unlocked, a visible "Lock Vault" / "Hide" button appears in the UI. When locked, this button vanishes completely.
|
||||
4. **Thumbnail & Cache Privacy:** Thumbnails and metadata of private videos must never leak into shared/public system caches or stay resident in GPU memory when locked.
|
||||
5. **No Clues in Default UI:** The default UI must not look like anything is missing or locked (no "Enter PIN to access Vault" prompt).
|
||||
|
||||
---
|
||||
|
||||
## 2. Comparative Analysis of Unlock Mechanisms
|
||||
|
||||
We have developed three distinct non-keyboard unlock concepts tailored for spatial computing, each with unique trade-offs in stealth, speed, and ergonomics.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph "Unlock Mechanisms"
|
||||
M1["Method 1: Cheat Code<br>(Grip + Face Button Combo)"]
|
||||
M2["Method 2: Spatial Corner Tap<br>(Hidden Hitboxes on Panel)"]
|
||||
M3["Method 3: Dual-Stick Chord<br>(Simultaneous Analog Directions)"]
|
||||
end
|
||||
|
||||
subgraph "Vault Lifecycle"
|
||||
LockedState["State: LOCKED (Default)<br>- Zero UI trace<br>- Path unlisted"]
|
||||
UnlockedState["State: UNLOCKED<br>- 'Vault' in Sidebar<br>- 'Lock' Button Visible"]
|
||||
end
|
||||
|
||||
M1 -->|Sequence Match| UnlockedState
|
||||
M2 -->|Sequence Match| UnlockedState
|
||||
M3 -->|Chord Match| UnlockedState
|
||||
UnlockedState -->|Click Lock Button / Combo / Sleep| LockedState
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Method 1: The "Controller Cheat Code" (Grip + Button Sequence) ⭐ Recommended
|
||||
|
||||
*Inspired by classic gaming cheat codes (e.g., Konami Code), adapted for VR motion controllers.*
|
||||
|
||||
- **How It Works:**
|
||||
1. The user holds down one of the controller grips fully (e.g., **Left Grip held > 75%**).
|
||||
2. While the grip is held, the user enters a short 4-button sequence using the face buttons on either controller (e.g. `X -> Y -> B -> A` or `X -> X -> Y -> Y`).
|
||||
3. A 2.5-second sliding timeout window resets the buffer if a pause occurs or an incorrect button is pressed.
|
||||
4. On successful sequence: A subtle double-haptic vibration buzzes in the controller, and the Vault immediately unlocks.
|
||||
- **Stealth Level:** **Maximum (5/5)**.
|
||||
- To anyone watching the player in real life or viewing the desktop mirror window, the player’s hands remain completely stationary. No laser beams move, no UI buttons highlight.
|
||||
- **Speed & Ergonomics:** Takes **under 1 second** to perform. Natural finger resting positions on Steam Frame / Quest / Index controllers.
|
||||
- **False-Positive Prevention:** Because a grip must be held continuously while a distinct button sequence is pressed, normal navigation (which uses single clicks of A, B, X, Y without holding grips) will never accidentally unlock the vault.
|
||||
|
||||
---
|
||||
|
||||
### Method 2: The "Secret UI Corner Pattern" (Spatial Easter Egg)
|
||||
|
||||
*Inspired by mobile OS developer-mode unlocking (e.g. tapping Android build number 7 times) and pattern locks.*
|
||||
|
||||
- **How It Works:**
|
||||
- The 1.8m x 1.12m Unified Spatial Hub floating panel has 4 invisible hit targets (5cm x 5cm) positioned at its four outer corners:
|
||||
- `[TL]` Top-Left
|
||||
- `[TR]` Top-Right
|
||||
- `[BR]` Bottom-Right
|
||||
- `[BL]` Bottom-Left
|
||||
- The user points the laser and clicks the trigger in a predefined sequence:
|
||||
- *Example Sequence:* `[TL] -> [TR] -> [BR] -> [TL]`.
|
||||
- *Alternative (Logo Multi-Tap):* Quadruple-clicking the "PIXEL VR" brand logo in the left navigation rail within 1.5 seconds.
|
||||
- **Stealth Level:** **Moderate (3/5)**.
|
||||
- An observer watching the desktop mirror might see the laser pointer flick to the corners. However, to someone in the physical room, it looks like normal browsing.
|
||||
- **Speed & Ergonomics:** Takes 2–3 seconds. Only requires one hand and the laser pointer.
|
||||
- **Pros:** Very discoverable and intuitive once known; no button memorization required.
|
||||
|
||||
---
|
||||
|
||||
### Method 3: The "Dual-Stick Analog Chord" (Physical Key Turn)
|
||||
|
||||
*Inspired by physical safe dials.*
|
||||
|
||||
- **How It Works:**
|
||||
- The user deflects both thumbsticks outward simultaneously (Left Stick pushed fully Left $\leftarrow$, Right Stick pushed fully Right $\rightarrow$) and clicks both stick buttons (L3 + R3) or pulls both triggers.
|
||||
- **Stealth Level:** **Maximum (5/5)**.
|
||||
- Completely invisible on screen mirrors.
|
||||
- **Speed & Ergonomics:** Near-instantaneous (0.5s).
|
||||
- **Cons:** Less sequence flexibility (can only be binary rather than a multi-step combination).
|
||||
|
||||
---
|
||||
|
||||
## 3. Recommended Hybrid Approach
|
||||
|
||||
We recommend implementing **Method 1 (Grip + Button Sequence)** as the primary unlock mechanism, with an optional fallback to **Method 2 (Logo Multi-Tap)** for maximum accessibility:
|
||||
|
||||
1. **Primary Combo:** Hold **Left Grip** + Press `X -> Y -> B -> A`.
|
||||
2. **Alternative Laser Gesture:** 4 rapid trigger clicks on the **"PIXEL VR" Logo** in the top-left rail.
|
||||
3. **Emergency Quick-Lock:** Repeating the combo from anywhere (even during full-screen video playback) immediately locks the vault and stops any private video currently playing.
|
||||
|
||||
---
|
||||
|
||||
## 4. UI Architecture & Presentation
|
||||
|
||||
### State 1: Locked (Default State)
|
||||
- **Files View:** Only normal directories (`Videos`, `Downloads`, `SMB Shares`, external drives) appear.
|
||||
- **Sidebar Rail:** Standard navigation items only (`Local Storage`, `Videos`, `SMB Shares`, `Downloads`).
|
||||
- **Settings:** Zero settings or toggles referencing the vault.
|
||||
- **Lock Button:** Completely absent and unrendered.
|
||||
|
||||
### State 2: Unlocked
|
||||
- **Sidebar Rail:** A new glowing pill button appears in the sidebar library list:
|
||||
- `[ 🔒 Vault ]` (or `[ Private ]` / `[ Stash ]`).
|
||||
- Clicking this pill immediately opens the vault folder in the 3x2 card grid.
|
||||
- **Action / Lock Button:** A dedicated **`[ 🔒 Lock Vault ]`** button appears in the navigation rail (either at the bottom of the sidebar or adjacent to the breadcrumbs).
|
||||
- **Files Grid:** Media cards inside the vault display normal 16:9 thumbnails, format tags, and durations.
|
||||
|
||||
### Relocking Behavior:
|
||||
When the user clicks `[ 🔒 Lock Vault ]` (or triggers the quick-lock combo):
|
||||
1. If the user is currently inside the vault directory, the browser instantly navigates back to `Home` (`openHome()`).
|
||||
2. If a video from the vault is currently playing, playback stops immediately and the screen resets.
|
||||
3. The `[ 🔒 Vault ]` entry and `[ 🔒 Lock Vault ]` button disappear.
|
||||
4. All memory buffers and GPU textures containing vault thumbnails are purged.
|
||||
|
||||
---
|
||||
|
||||
## 5. Filesystem & Privacy Architecture
|
||||
|
||||
### 1. Storage Location
|
||||
- **Path:** `~/.pixelvr_vault/` (or `~/Videos/.vault/`).
|
||||
- By utilizing a standard UNIX dotfile directory (`.pixelvr_vault`), the folder is automatically hidden from standard file managers in SteamOS Desktop Mode (Dolphin) unless "Show Hidden Files" is explicitly turned on.
|
||||
- The directory will support sub-folders (e.g. `~/.pixelvr_vault/3D_VR180/`, `~/.pixelvr_vault/Movies/`).
|
||||
|
||||
### 2. Thumbnail Isolation & Zero-Leakage Guarantee
|
||||
- **Vulnerability in Standard Caching:** Usually, thumbnail generators save image previews into a shared directory like `~/.cache/pixelvr/thumbs/` with predictable names. Anyone exploring the cache could see the private video previews.
|
||||
- **The Vault Solution:**
|
||||
- Thumbnails for files located within `~/.pixelvr_vault/` are stored strictly inside `~/.pixelvr_vault/.thumbs/`.
|
||||
- When the vault is locked, all in-memory cache entries pointing to `~/.pixelvr_vault/` are evicted from the `ThumbnailCache` map, and the 6 GPU card thumbnail textures in `VulkanRenderer` are cleared to null/placeholders.
|
||||
|
||||
### 3. Downloads Integration
|
||||
- When the vault is unlocked, downloading an SMB file can offer a quick-action: `[Download to Vault]`, allowing users to transfer files directly into the hidden folder over the network without placing them in the public `~/media` folder first.
|
||||
|
||||
---
|
||||
|
||||
## 6. Auto-Lock Safety Policies (Anti-Exposure Guards)
|
||||
|
||||
To protect the user from accidentally leaving their private media visible:
|
||||
|
||||
| Event | Action Taken |
|
||||
|---|---|
|
||||
| **Headset Removed** (Proximity sensor detects headset off) | **Instant Lock & Return to Home** |
|
||||
| **SteamVR Dashboard Opened** (User presses system menu button) | **Instant Lock** |
|
||||
| **App Minimized / Focus Lost** | **Instant Lock** |
|
||||
| **Inactivity Timeout** (e.g. 5 minutes with no controller input) | **Auto-Lock & Return to Home** |
|
||||
| **App Close / Restart** | **Always Cold Boots into Locked State** |
|
||||
|
||||
---
|
||||
|
||||
## 7. Configuration & Customization Options (Optional Future Settings)
|
||||
|
||||
Once the vault is unlocked, an advanced item can appear in the **Settings > Privacy** tab:
|
||||
- **Change Unlock Combo:** Allows the user to record their own 4-step button sequence.
|
||||
- **Auto-Lock Timer:** Adjust timeout (`2 min`, `5 min`, `10 min`, `Never while active`).
|
||||
- **Emergency Panic Button:** Mapping a specific physical gesture (e.g., pressing both thumbsticks for 1 second) to immediately stop playback, lock vault, and switch to a decoy video or home screen.
|
||||
@@ -4,8 +4,49 @@ This guide covers how to get the built app running on the headset, the available
|
||||
options, and which one to pick.
|
||||
|
||||
**What you're shipping:** a single self-contained ARM64 binary
|
||||
(`build-aarch64/bin/pixelvr`, ~11 MB, static OpenXR loader) plus four SPIR-V
|
||||
shaders. That's it — no `libopenxr_loader.so` or other bundled libraries.
|
||||
(`build-aarch64/bin/pixelvr`, ~11 MB, static OpenXR loader) plus SPIR-V shaders
|
||||
and a shell launcher. That's it — no `libopenxr_loader.so` or other bundled
|
||||
libraries.
|
||||
|
||||
---
|
||||
|
||||
## Just installing a release (no devkit, no build)
|
||||
|
||||
End users do not need to build anything or use devkits. Choose whichever option fits your setup:
|
||||
|
||||
1. **USB Drive / MicroSD Card (No typing & No Dev Mode needed):**
|
||||
Download `pixelvr-linux-arm64.tar.gz` from GitHub Releases onto a USB flash drive or MicroSD card, plug it into the headset, switch to **Desktop Mode**, and double-click **`Install Pixel VR Player.desktop`**.
|
||||
2. **From your PC over Wi-Fi (Requires SSH or Dev Mode):**
|
||||
Run [`tools/install-from-pc.bat`](../tools/install-from-pc.bat) (Windows) or [`tools/install-from-pc.sh`](../tools/install-from-pc.sh) (Mac/Linux) and enter the headset's IP address.
|
||||
3. **One command in terminal (Desktop Mode / SSH):**
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Pixelplanet/Pixel-VR-Player/main/tools/install.sh | sh
|
||||
```
|
||||
|
||||
The installer verifies checksums, installs to `~/PixelVRPlayer`, and registers **Pixel VR Player** as a Non-Steam Game in your library. Re-running upgrades in-place while keeping all settings, thumbnails, and hidden vault files (`~/.pixelvr_vault`).
|
||||
|
||||
### Uninstalling
|
||||
|
||||
To cleanly remove the application and its Steam shortcut:
|
||||
|
||||
```bash
|
||||
# On headset:
|
||||
~/PixelVRPlayer/install.sh --uninstall
|
||||
|
||||
# Or one-liner:
|
||||
curl -fsSL https://raw.githubusercontent.com/Pixelplanet/Pixel-VR-Player/main/tools/install.sh | sh -s -- --uninstall
|
||||
|
||||
# Or from PC over Wi-Fi:
|
||||
./tools/uninstall-from-pc.sh # Mac/Linux
|
||||
tools/uninstall-from-pc.bat # Windows
|
||||
```
|
||||
|
||||
To purge user settings, thumbnails, and vault files:
|
||||
```bash
|
||||
rm -rf ~/.config/pixelvr ~/.cache/pixelvr ~/.pixelvr_vault
|
||||
```
|
||||
|
||||
The rest of this document covers building and deploying as a developer.
|
||||
|
||||
---
|
||||
|
||||
@@ -20,6 +61,12 @@ shaders. That's it — no `libopenxr_loader.so` or other bundled libraries.
|
||||
|
||||
# 2b. Or stage a clean folder for the GUI Devkit Client:
|
||||
./tools/stage.sh # -> dist/PixelVRPlayer/
|
||||
|
||||
# 2c. Or assemble release tarball with desktop installer:
|
||||
./tools/package-release.sh build-aarch64 pixelvr-linux-arm64.tar.gz v1.4.2
|
||||
|
||||
# 2d. Capture in-headset screenshots over OpenVR / SteamVR compositor:
|
||||
python tools/take_headset_screenshot.py --delay 5
|
||||
```
|
||||
|
||||
---
|
||||
@@ -60,7 +107,7 @@ All three methods below share the same setup. Do this once.
|
||||
| Method | Per-deploy effort | Best for |
|
||||
|--------|-------------------|----------|
|
||||
| **A. Devkit Client (GUI)** | click Upload → Start | first run; the official, guided path |
|
||||
| **B. `tools/deploy.py` (CLI)** | one command | fast iteration during development |
|
||||
| **B. `tools/deploy.sh` (CLI)** | one command | fast iteration during development |
|
||||
| **C. Manual SSH + scp** | a few commands | debugging, capturing logs live |
|
||||
| ~~ADB / Android~~ | — | APKs only — **not** this app |
|
||||
|
||||
@@ -68,8 +115,7 @@ All three methods below share the same setup. Do this once.
|
||||
- **Easiest to iterate:** Option B (one command builds nothing you didn't ask for
|
||||
and launches in the headset).
|
||||
|
||||
They all end up in the same place: *Steam ▸ Library ▸ Non-Steam ▸ **Devkit Game:
|
||||
PixelVRPlayer***.
|
||||
They all end up in the same place: *Steam ▸ Library ▸ Non-Steam ▸ **Pixel VR Player***.
|
||||
|
||||
---
|
||||
|
||||
@@ -101,7 +147,7 @@ The official, no-scripting path.
|
||||
|
||||
---
|
||||
|
||||
## Option B — One command (`tools/deploy.py`)
|
||||
## Option B — One command (`tools/deploy.sh`)
|
||||
|
||||
Best for the edit-build-run loop. Copies the binary, shaders and `launch.sh` into
|
||||
`~/devkit-game/PixelVRPlayer/`, registers the Non-Steam shortcut, and (optionally)
|
||||
@@ -154,7 +200,7 @@ differ in which system libraries it sees:
|
||||
environment. This is what Valve's docs recommend and what the **GUI (Option A)**
|
||||
selects. Good default.
|
||||
- **Directly on the host** — uses SteamOS's own FFmpeg/SteamVR libraries. This is
|
||||
what **`deploy.py` (Option B)** registers, matching how a shipping native OpenXR
|
||||
what **`deploy.sh` (Option B)** registers, matching how a shipping native OpenXR
|
||||
player runs.
|
||||
|
||||
Both expose the SteamVR OpenXR runtime, so both can work. If the app fails to load
|
||||
@@ -205,6 +251,25 @@ Requirements:
|
||||
|
||||
---
|
||||
|
||||
## Taking in-headset screenshots
|
||||
|
||||
To capture real in-headset screenshots directly from the SteamVR compositor while the app is running:
|
||||
|
||||
```bash
|
||||
# Capture with a 5-second countdown to put the headset on:
|
||||
python tools/take_headset_screenshot.py --delay 5
|
||||
|
||||
# Point to a specific headset IP:
|
||||
python tools/take_headset_screenshot.py --host steamos@192.168.178.61 --delay 3
|
||||
|
||||
# Continuous series (e.g. 5 captures spaced 2 seconds apart):
|
||||
python tools/take_headset_screenshot.py --interval 2 --count 5
|
||||
```
|
||||
|
||||
The script connects over SSH, triggers SteamVR's compositor screenshot API, fetches the resulting PNGs, and places them into `docs/screenshots/` with both the raw stereo composite (`_stereo.png`) and a cropped single-eye preview (`_preview.png`).
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Likely cause / fix |
|
||||
|
||||
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 29 KiB |
@@ -0,0 +1,201 @@
|
||||
# Pixel VR Player: Implementation Plan for Modern Spatial UI
|
||||
|
||||
This document details the concrete implementation plan to bring **Mockup #1 (Spatial Card Media Browser)** and **Mockup #2 (Floating Cinema Transport Dock)** into Pixel VR Player, including the **Flat vs. Curved Cinema Screen** engine and **180°/360° VR contextual adaptations**, without breaking existing hardware-accelerated playback or network functionality.
|
||||
|
||||
---
|
||||
|
||||
## 1. Architectural Overview & Design Decisions
|
||||
|
||||
### Selected Concepts
|
||||
- **File Browser:** **Concept #1 (Spatial Media Browser)** — 3x2 / 4x2 responsive card grid, 16:9 thumbnail previews, format badges (`VR180 3D`, `4K HEVC`), breadcrumb path bar, source tabs (`Local`, `SMB Shares`, `Downloads`), left navigation rail.
|
||||
- **Video Controls:** **Concept #2 (Floating Transport Dock)** — Frosted glassmorphic pill dock, prominent glowing central Play/Pause hero button, wide tactile scrubber bar with buffered track & hover timestamp, quick format pill (`VR 180° SBS`), audio track pill (`ENG 5.1`), screen distance/tilt controls, and download status badge.
|
||||
- **Cinema Screen:** **Parametric Flat ↔ Curved Screen Engine** — Dynamically adjustable curvature (from $0.0$ flat plane to $1.0$ IMAX-style cylindrical wrap around the viewer).
|
||||
- **180° / 360° VR Adaptation:** Context-aware HUD positioning, torso/yaw "lazy follow", auto-hide fading, and 180°-specific controls (Horizon Tilt & FOV Zoom).
|
||||
|
||||
### Visual Mockups
|
||||
|
||||
#### Mockup #1: Spatial Media Browser (Card Grid & Navigation Rail)
|
||||

|
||||
|
||||
#### Mockup #2: Floating Cinema Transport Dock (Interactive Control HUD)
|
||||

|
||||
|
||||
#### Mockup #3: Minimalist Floating HUD Alternative
|
||||

|
||||
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph "Vulkan Graphics Pipeline"
|
||||
QuadVert["quad.vert (Parametric Curvature)"]
|
||||
UIFrag["ui.frag (SDF Pills, Halos, Frosted Glass)"]
|
||||
TextEngine["text_atlas.cpp (Proportional Sans-Serif SDF)"]
|
||||
ThumbPool["VkImage Thumbnail Texture Array"]
|
||||
end
|
||||
|
||||
subgraph "Core UI Layer (C++20)"
|
||||
BrowserView["Browser Grid & Navigation (ui/browser.cpp)"]
|
||||
ThumbService["Thumbnail Extractor (media/thumb_service.cpp)"]
|
||||
HUDView["Transport Dock & 180VR Tracker (ui/hud_view.cpp)"]
|
||||
SettingsDrawer["Tabbed Settings Modal (ui/settings_menu.cpp)"]
|
||||
end
|
||||
|
||||
subgraph "Playback Engine"
|
||||
HWDecoder["FFmpeg V4L2 M2M Zero-Copy"]
|
||||
OpenXRInput["Valve Steam Frame Controllers"]
|
||||
end
|
||||
|
||||
QuadVert --> HWDecoder
|
||||
UIFrag --> HUDView
|
||||
UIFrag --> BrowserView
|
||||
TextEngine --> BrowserView
|
||||
TextEngine --> HUDView
|
||||
ThumbPool --> ThumbService
|
||||
ThumbService --> BrowserView
|
||||
OpenXRInput --> HUDView
|
||||
OpenXRInput --> BrowserView
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Flat vs. Curved Cinema Screen Engine
|
||||
|
||||
### 2.1 The Problem
|
||||
Currently, in `shaders/quad.vert`, the flat screen is a hardcoded 6-vertex 2D planar quad (`vec2 positions[6] = ...`). It cannot curve, which leads to edge perspective stretching and focal mismatch in VR headsets when viewing large cinema screens.
|
||||
|
||||
### 2.2 The Curved Mesh Solution
|
||||
We replace the 2-triangle flat quad with a **tessellated parametric cylindrical ribbon** (e.g. 64 horizontal subdivisions):
|
||||
|
||||
$$\theta(u) = (u - 0.5) \cdot \alpha_{\text{arc}}, \quad \text{where } \alpha_{\text{arc}} = \frac{W_{\text{screen}}}{R} \cdot \text{curvature}$$
|
||||
|
||||
$$x(u) = R \cdot \sin(\theta(u)), \quad z(u) = -R \cdot (1 - \cos(\theta(u)))$$
|
||||
|
||||
- **Curvature Parameter ($C \in [0.0, 1.0]$):**
|
||||
- $C = 0.0$: Perfectly flat planar screen ($z = 0$).
|
||||
- $C = 0.5$: Gentle cinema curve (subtle focal distance equalization).
|
||||
- $C = 1.0$: Immersive IMAX deep wrap (constant focal distance to the eyes across the entire field of view).
|
||||
- **Aspect Ratio & Stereo Preservation:**
|
||||
- Full SBS, Half-SBS, and Top-Bottom stereo scaling remain completely intact.
|
||||
- Anamorphic widescreen formats (2.39:1, 21:9) wrap naturally around the viewer.
|
||||
- **In-HUD & Settings Control:**
|
||||
- Users can toggle between **Flat** and **Curved** or adjust curvature smoothly using thumbstick left/right while pointing at the screen geometry pill.
|
||||
|
||||
---
|
||||
|
||||
## 3. 180° and 360° VR Video Adaptations
|
||||
|
||||
When playing VR180 or 360° videos, the video is projected onto a hemisphere/sphere around the user. The UI needs specific behavioral adaptations:
|
||||
|
||||
### 3.1 Lazy-Follow (Torso / Gaze Smoothing)
|
||||
- **Problem:** If the HUD is locked in world space, turning around in a 180°/360° scene leaves the controls behind you. If rigidly head-locked, the controls shake with head micro-movements and induce motion sickness.
|
||||
- **Solution (Torso Follow with Deadband):**
|
||||
- Allow a **$\pm 20^\circ$ yaw deadband**: Small head turns leave the HUD stationary.
|
||||
- When the user turns past $20^\circ$, the HUD smoothly glides (exponential damping, $\tau = 0.35\text{s}$) to catch up with their forward orientation.
|
||||
- Positioned $15^\circ$ below horizontal eye-level so it never blocks the primary focal center of the 180VR scene.
|
||||
|
||||
### 3.2 Auto-Fade Immersion Timer
|
||||
- During active 180VR/360VR playback, the HUD automatically fades out after **3.5 seconds** of inactivity.
|
||||
- Woken up instantly by:
|
||||
- Moving the controller (velocity threshold).
|
||||
- Touching the thumbstick or trigger.
|
||||
- Looking downward toward the dock position.
|
||||
|
||||
### 3.3 Dynamic Context Controls
|
||||
In Flat/Cinema mode, the controls adjust *Screen Distance* and *Screen Curvature*. In 180VR/360VR, these concepts do not exist. The controls dynamically adapt:
|
||||
|
||||
| Feature | Cinema / Flat Mode | 180° / 360° VR Mode |
|
||||
| :--- | :--- | :--- |
|
||||
| **Geometry Pill** | `[ Flat / Curved Screen ]` | `[ Horizon Level / FOV ]` |
|
||||
| **Thumbstick Up/Down** | Screen Distance (1.5m – 7.0m) | Horizon Pitch Tilt (`spherePitch_`) |
|
||||
| **Thumbstick Left/Right** | Screen Curvature (0% – 100%) | FOV Zoom / IPD scale (`sphereZoom_`) |
|
||||
| **Bumper Action** | Recenter Screen to view line | Recenter 180° Longitude to view line |
|
||||
| **Comfort Depth** | Positioned just below bottom bezel | Floats at 1.8m depth, angled 18° up toward eyes |
|
||||
|
||||
---
|
||||
|
||||
## 4. Step-by-Step Implementation Roadmap
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
P1["Phase 1<br>Vulkan Shaders & Fonts"] --> P2["Phase 2<br>Curved Screen Engine"]
|
||||
P2 --> P3["Phase 3<br>Transport Dock (HUD)"]
|
||||
P3 --> P4["Phase 4<br>Thumbnail Engine"]
|
||||
P4 --> P5["Phase 5<br>Spatial Media Browser"]
|
||||
P5 --> P6["Phase 6<br>180VR Polish & QA"]
|
||||
```
|
||||
|
||||
### Phase 1: Vulkan Shaders & Proportional Font Engine
|
||||
*Goal: Provide the visual primitives for modern UI without touching playback logic.*
|
||||
1. **Shader Primitives (`shaders/ui.frag`):**
|
||||
- Add new SDF shape types:
|
||||
- `UiShape::Pill` (stadium shape for badges and buttons).
|
||||
- `UiShape::GlowRing` (for hero play/pause button active halo).
|
||||
- `UiShape::GradientCard` (smooth two-stop vertical gradient for cards).
|
||||
- Add frosted glass background tinting with antialiased borders.
|
||||
2. **Proportional Font Engine (`src/gfx/text_atlas.cpp`):**
|
||||
- Update `buildFontAtlas` to support proportional fonts (`Inter-Regular.ttf` / `Roboto-Medium.ttf`).
|
||||
- Store proportional advance and bearing metrics in `Glyph` struct.
|
||||
- Maintain ASCII & common Unicode symbol support (`✓`, `‹`, `›`, `✕`, `●`, `★`).
|
||||
|
||||
### Phase 2: Parametric Curved Screen Engine
|
||||
*Goal: Support seamless switching between flat cinema screen and curved cinema screen.*
|
||||
1. **Curvature Geometry in `vulkan_renderer.cpp`:**
|
||||
- Create a static index/vertex buffer for a 64x1 tessellated ribbon or evaluate parametric cylindrical curvature in `quad.vert`.
|
||||
- Pass `screenCurvature` ($0.0 \dots 1.0$) through the vertex push constants.
|
||||
2. **Raycasting Update:**
|
||||
- When raycasting against the curved screen (for direct laser pointer scrub), intersect ray with cylinder $(x^2 + z^2 = R^2)$.
|
||||
|
||||
### Phase 3: Modern Floating Transport Dock (Mockup #2)
|
||||
*Goal: Implement the sleek, intuitive playback dock.*
|
||||
1. **Dock Layout Architecture:**
|
||||
- Left section: Download status badge & duration readout.
|
||||
- Center section: Hero Play/Pause button ($\varnothing 68\text{mm}$) + Seek -10s / +10s + tactile scrubber.
|
||||
- Right section: Format Pill (`VR 180° SBS`), Audio Pill (`ENG 5.1`), Geometry Pill (`Flat/Curved` or `Horizon/FOV`).
|
||||
2. **180VR Lazy Follow & Auto-Hide:**
|
||||
- Integrate yaw deadband tracking and alpha fade timer into `src/app/app.cpp` and `vulkan_renderer.cpp`.
|
||||
- Smooth alpha transition ($0.0 \to 1.0$ in 150ms).
|
||||
|
||||
### Phase 4: Thumbnail Extraction & Caching Engine
|
||||
*Goal: Enable visual preview cards in the browser without UI thread stalls.*
|
||||
1. **Lightweight Extractor (`src/media/thumbnail_service.cpp`):**
|
||||
- Background worker thread with queue.
|
||||
- For local and downloaded files: reads first keyframe at $t \approx 10\%$ using `avformat_open_input` + `avcodec_receive_frame`.
|
||||
- Rescales to 256x144 RGBA thumbnail.
|
||||
- Caches to `~/.cache/pixelvr/thumbs/<hash>.bin`.
|
||||
2. **Vulkan Texture Pool:**
|
||||
- Allocate a `VkImage` 2D Texture Array (e.g. 16–32 slots) for currently visible browser cards.
|
||||
- Stream loaded thumbnails into the array descriptors.
|
||||
|
||||
### Phase 5: Spatial Media Browser (Mockup #1)
|
||||
*Goal: Implement the 3D curved card grid file browser.*
|
||||
1. **Browser Layout Tree:**
|
||||
- Left Navigation Rail (Home, Videos, SMB Shares, Downloads).
|
||||
- Top Header (Breadcrumb trail, Source tabs, View toggle).
|
||||
- 3x2 Grid of Media Cards:
|
||||
- 16:9 Thumbnail quad.
|
||||
- Title string with proportional font.
|
||||
- Format pill badges (`VR180 3D`, `4K HEVC`).
|
||||
- Duration pill (`32:45`).
|
||||
2. **Input & Pointer Physics:**
|
||||
- 2D grid raycasting: maps hit point directly to `(col, row)`.
|
||||
- Magnetic snap: gentle suction on laser ray when near card center.
|
||||
- Smooth thumbstick grid navigation (Up/Down/Left/Right).
|
||||
|
||||
### Phase 6: System Verification & Rigorous QA
|
||||
*Goal: Ensure zero regressions across both desktop and ARM64 hardware.*
|
||||
1. **Hardware Validation:**
|
||||
- Cross-compile for aarch64 Steam Frame.
|
||||
- Verify 90 Hz / 120 Hz render loop budget with Vulkan pipeline timers.
|
||||
- Confirm zero-copy DMA-BUF hardware decode remains unaffected.
|
||||
2. **Functional Validation:**
|
||||
- SMB network streaming and background downloading.
|
||||
- Multi-audio stream switching and eye swapping.
|
||||
- Controller button remapping.
|
||||
|
||||
---
|
||||
|
||||
## 5. Non-Breaking Safety Guarantees
|
||||
|
||||
1. **Zero External Heavy Dependencies:** No Qt, no WebViews, no massive libraries. Everything builds with existing CMake toolchain and Vulkan/OpenXR/FreeType/FFmpeg stack.
|
||||
2. **Graceful Fallback:** If thumbnails are not yet extracted or unavailable (e.g. slow SMB network share), the browser instantly displays clean stylized gradient placeholder cards with format icons.
|
||||
3. **Model Separation:** `Browser` and `SettingsMenu` remain headless data models. All new visual enhancements live purely in the presentation/renderer layer.
|
||||
@@ -0,0 +1,144 @@
|
||||
# PixelVR Unified Spatial Hub Architecture & UI Redesign Specification
|
||||
|
||||
## 1. Executive Summary & Design Paradigm Shift
|
||||
|
||||
### The Problem With Separate Overlays
|
||||
In previous iterations, the VR player utilized three disconnected overlay windows with distinct dimensional footprints and conflicting layout logic:
|
||||
1. **File Browser:** A 3x2 card grid with an ad-hoc left sidebar.
|
||||
2. **Downloads Window:** A smaller centered modal with custom progress rows.
|
||||
3. **Settings Overlay:** A tall vertical list that occluded the screen.
|
||||
|
||||
Because these interfaces were built as disjointed panels, buttons and text had inconsistent paddings. When text lengths varied, font scaling was applied dynamically, creating an unpolished look where adjacent buttons had wildly different font sizes. Furthermore, controller laser raycasting had to switch between multiple collision hulls.
|
||||
|
||||
### The Solution: One Unified Spatial Window
|
||||
We unify the entire non-playback experience into a single, cohesive **Unified Spatial Hub** floating panel (1.8m x 1.1m, curved 15° radius).
|
||||
- **Fixed, Consistent Dimensions:** Button heights, pill paddings, and typography sizes are standardized across all screens.
|
||||
- **Section Tabs in Left Navigation Rail:**
|
||||
- `FILES` (Browse local storage, videos, SMB shares, external drives)
|
||||
- `DOWNLOADS` (Monitor active downloads with live speed, ETA, progress bars, pause/cancel controls, and completed library)
|
||||
- `SETTINGS` (Configure cinema screen curvature, distance, hardware decode engine, projection, laser controls)
|
||||
- **Single Raycast Hull & State Machine:** The controller laser interacts with one unified surface with dedicated collision zones, eliminating edge cases where rays pass into infinity or click through hidden layers.
|
||||
|
||||
---
|
||||
|
||||
## 2. Global Design System & Spatial Metric Tokens
|
||||
|
||||
All coordinates and dimensions are defined in metric VR panel-local units:
|
||||
|
||||
| Token | Metric Value | Purpose |
|
||||
|---|---|---|
|
||||
| `kHubWidth` | `1.80m` | Total unified floating window width |
|
||||
| `kHubHeight` | `1.12m` | Total unified floating window height |
|
||||
| `kNavRailWidth` | `0.38m` | Left navigation rail width |
|
||||
| `kContentWidth` | `1.36m` | Main content area width (`kHubWidth - kNavRailWidth - padding`) |
|
||||
| `kNavButtonHeight` | `0.065m` | Standard height for primary navigation pills |
|
||||
| `kButtonPadX` | `0.028m` | Uniform horizontal padding for all interactive pills |
|
||||
| `kStandardFontSize` | `0.024m` | Primary font size for all UI button labels |
|
||||
| `kTitleFontSize` | `0.032m` | Screen headers, modal titles |
|
||||
| `kDetailFontSize` | `0.018m` | Secondary status, subtitles, file size/specs |
|
||||
| `kBadgeFontSize` | `0.014m` | Chip tags (`VR180 3D`, `4K HEVC`) |
|
||||
| `kCardWidth` | `0.42m` | Media card width in 3x2 grid |
|
||||
| `kCardHeight` | `0.38m` | Media card height in 3x2 grid |
|
||||
| `kThumbAspect` | `16:9` | Fixed 16:9 aspect ratio for thumbnail preview boxes |
|
||||
|
||||
### Color Palette (Glassmorphism & Cyber-Cinema)
|
||||
- **Panel Backdrop:** `rgba(6, 9, 15, 0.94)` with frosted glass border `rgba(50, 75, 115, 0.40)`
|
||||
- **Navigation Rail:** `rgba(4, 6, 11, 0.96)` with separator divider `rgba(45, 65, 95, 0.50)`
|
||||
- **Interactive Button Normal:** `rgba(18, 26, 42, 0.70)` with border `rgba(55, 80, 120, 0.50)`
|
||||
- **Interactive Button Hover:** `rgba(35, 75, 140, 0.90)` with border `rgba(100, 190, 255, 1.00)` + glow
|
||||
- **Active / Selected Pill:** `rgba(20, 110, 210, 0.95)` with border `rgba(120, 210, 255, 1.00)`
|
||||
- **Accent Primary (Cyan):** `rgba(70, 195, 255, 1.00)`
|
||||
- **Accent Success (Emerald):** `rgba(35, 195, 105, 1.00)`
|
||||
- **Text Primary (White):** `rgba(245, 248, 255, 1.00)`
|
||||
- **Text Secondary (Slate):** `rgba(145, 175, 215, 0.85)`
|
||||
|
||||
---
|
||||
|
||||
## 3. UI Page Layout Specifications & Mockups
|
||||
|
||||
### Page 1: Files Section (Spatial Media Browser)
|
||||

|
||||
|
||||
- **Top Bar:**
|
||||
- Dynamic breadcrumb display with overflow protection: `Local Storage > Videos > VR180`
|
||||
- Right-aligned page switcher: `PAGE [ < ] 1 / 4 [ > ]` and sort dropdown.
|
||||
- **Main Grid (3x2 Media Cards):**
|
||||
- Six cards per page.
|
||||
- Card upper 65% is dedicated to a 16:9 thumbnail preview with rounded corners.
|
||||
- Overlaid tags: Top-left format badge (`VR180 3D`, `4K HEVC`), bottom-right duration pill (`42:15`).
|
||||
- Card lower 35% contains fixed-size, multi-line typography:
|
||||
- Line 1: Video Title (truncated with ellipsis if length > card text area, preserving standard font size).
|
||||
- Line 2: Details: `7200x3600 • 59.94 FPS • 14.2 GB`
|
||||
- **Bottom Status Rail:** Action key hints: `[Trigger: Play] [X: Download to Local] [Thumbstick: Navigate]`.
|
||||
|
||||
---
|
||||
|
||||
### Page 2: Downloads Section (Active Transfers & Queue Manager)
|
||||

|
||||
|
||||
- **Top Bar:** `Downloads > Active Transfers` with aggregate stats `Total Speed: 28.4 MB/s • Free Space: 184 GB`.
|
||||
- **Main View (Active Job Cards):**
|
||||
- Each job is rendered as a wide card (`1.32m x 0.12m`):
|
||||
- Left: File thumbnail / icon.
|
||||
- Center Top: Filename with standardized font size.
|
||||
- Center Mid: Dynamic progress bar with smooth cyan glow gradient fill.
|
||||
- Center Bottom: Metrics line: `Transferred: 6.4 GB / 14.8 GB (43%) • Speed: 18.2 MB/s • ETA: 07:42`.
|
||||
- Right: Action buttons (`Pause/Resume`, `Cancel (X)`) with fixed 0.05m circular dimensions.
|
||||
- **Completed Transfers Section:**
|
||||
- Displays finished downloads with an emerald green `Completed` pill and quick-action `[Play Now]` or `[Open In Folder]` buttons.
|
||||
- **Bottom Control Bar:** Global buttons: `[Pause All]`, `[Resume All]`, `[Clear Finished]`.
|
||||
|
||||
---
|
||||
|
||||
### Page 3: Settings Section (Cinema & Playback Configuration)
|
||||

|
||||
|
||||
- **Top Bar:** `Settings > Display & Playback` with version pill `v1.2.0 • Steam Frame`.
|
||||
- **Two-Column Settings Grid:**
|
||||
- **Screen Geometry:** Segmented pill toggle: `[Flat Screen]` vs `[Curved Cinema]`.
|
||||
- **Screen Distance & Scale:** Dual slider controls (`Distance: 1.5m - 5.0m`, `Scale: 80% - 200%`).
|
||||
- **Hardware Video Decoder:** Segmented toggle: `[Auto (Iris/V4L2)]` vs `[Software (Multi-Thread)]`.
|
||||
- **Stereo Projection Default:** `[Auto-Detect]` | `[VR180 SBS]` | `[360 Mono]` | `[2D Cinema]`.
|
||||
- **Controller Laser Mode:** `[Right Hand Only]` | `[Left Hand Only]` | `[Both Hands]`.
|
||||
- **Network / SMB Shares:** Dedicated button to trigger the share credentials modal.
|
||||
|
||||
---
|
||||
|
||||
### Page 4: In-Playback Cinema Floating Transport HUD
|
||||

|
||||
|
||||
- **Location:** Floating at `y = -0.65m, z = -2.2m`, tilted 20° up toward viewer.
|
||||
- **Scrubber Bar (Top Row):**
|
||||
- Full-width interactive timeline scrubber with elapsed time (`18:24`) and remaining (`-41:36`).
|
||||
- Laser hover reveals a floating mini-thumbnail tooltip at the seek position.
|
||||
- **Center Transport Cluster:**
|
||||
- 4 primary circular buttons with generous touch targets:
|
||||
`[Stop]` `[Seek -10s]` `(Play/Pause)` `[Seek +10s]`.
|
||||
- **Left Flank:**
|
||||
- `[HUB]` button (opens the Unified Spatial Hub).
|
||||
- `[RECENTER]` button (yaw/pitch reset).
|
||||
- **Right Flank:**
|
||||
- `[FORMAT]` toggle pill (cycles 2D, 3D SBS, VR180, 360).
|
||||
- `[AUDIO]` track selector pill (greys out to `DEFAULT` if only 1 audio stream exists).
|
||||
- `[SCREEN]` toggle pill (toggles Flat / Curved geometry).
|
||||
|
||||
---
|
||||
|
||||
## 4. Root Causes & Direct Fixes for Previous Issues
|
||||
|
||||
### Issue A: Thumbnail Generation Failure on Headset
|
||||
- **Root Cause Identified:**
|
||||
1. `ThumbnailCache::workerLoop` ran `ffmpeg` without `-nostdin` or with `-threads 4` inside `std::system`. When ffmpeg prompts for stdin or encounters an existing file without `-y` in certain subshells, it fails.
|
||||
2. Thumbnail request targets in `app.cpp` passed `/home/steamos/Downloads/.../sample.mkv` but some MKV/MP4 files had deep directories where `ffmpeg` needed `-ss 00:00:05` before `-i` and `-nostdin` to prevent process suspension.
|
||||
3. The descriptor set allocation for thumbnail slots was previously leaking or getting overwritten.
|
||||
- **Solution for Implementation:**
|
||||
- Run ffmpeg with: `ffmpeg -nostdin -y -v error -ss 00:00:05 -i "<file>" -vframes 1 -s 256x144 -f rawvideo -pix_fmt rgba "<cacheFile>"`.
|
||||
- Add fallback to `-ss 00:00:00`.
|
||||
- Pre-allocate a dedicated `VkDescriptorPool` of 6 persistent image sampler sets exclusively for the 6 card slots so no descriptor reallocation occurs during runtime.
|
||||
|
||||
### Issue B: Inconsistent Text Sizes Across Buttons
|
||||
- **Root Cause Identified:**
|
||||
The previous `drawFit` function dynamically shrunk the font size of each button individually based on the length of its text. This caused short labels like `HOME` to look huge while longer labels looked tiny and misaligned.
|
||||
- **Solution for Implementation:**
|
||||
- Fix button width and font scale globally to `kStandardFontSize`.
|
||||
- If a label exceeds the generous button width, wrap or cleanly truncate with an ellipsis (`...`), keeping all button font scales strictly identical across the interface.
|
||||
|
After Width: | Height: | Size: 249 KiB |
|
After Width: | Height: | Size: 207 KiB |
|
After Width: | Height: | Size: 556 KiB |
|
After Width: | Height: | Size: 465 KiB |
@@ -1,26 +1,53 @@
|
||||
#version 450
|
||||
|
||||
// Textured video screen: a 16:9 quad whose distance, yaw and aspect are applied
|
||||
// by the model matrix on the CPU. Geometry and UVs are hardcoded and indexed by
|
||||
// gl_VertexIndex (no vertex buffer).
|
||||
// Textured video screen: a 16:9 screen with parametric curvature (Flat -> Curved).
|
||||
// 64 horizontal segments (384 vertices) are procedurally evaluated from gl_VertexIndex.
|
||||
// pc.stereo.w carries the curvature factor in [0.0, 1.0] (0 = flat, >0 = curved arc).
|
||||
|
||||
layout(push_constant) uniform Push {
|
||||
mat4 mvp;
|
||||
vec4 conversion;
|
||||
vec4 range;
|
||||
vec4 stereo;
|
||||
vec4 stereo; // x: stereoMode, y: viewIndex, z: swapEyes, w: curvature
|
||||
} pc;
|
||||
|
||||
layout(location = 0) out vec2 vUV;
|
||||
|
||||
void main() {
|
||||
vec2 positions[6] = vec2[](
|
||||
vec2(-1.6, 0.9), vec2(-1.6, -0.9), vec2( 1.6, -0.9),
|
||||
vec2(-1.6, 0.9), vec2( 1.6, -0.9), vec2( 1.6, 0.9));
|
||||
vec2 uvs[6] = vec2[](
|
||||
vec2(0.0, 0.0), vec2(0.0, 1.0), vec2(1.0, 1.0),
|
||||
vec2(0.0, 0.0), vec2(1.0, 1.0), vec2(1.0, 0.0));
|
||||
const int kSegments = 64;
|
||||
int seg = gl_VertexIndex / 6;
|
||||
int vid = gl_VertexIndex % 6;
|
||||
|
||||
gl_Position = pc.mvp * vec4(positions[gl_VertexIndex], 0.0, 1.0);
|
||||
vUV = uvs[gl_VertexIndex];
|
||||
float u0 = float(seg) / float(kSegments);
|
||||
float u1 = float(seg + 1) / float(kSegments);
|
||||
|
||||
// Quad vertex coordinates for this segment
|
||||
float u = u0;
|
||||
float y = 0.9;
|
||||
if (vid == 1) { u = u0; y = -0.9; }
|
||||
else if (vid == 2) { u = u1; y = -0.9; }
|
||||
else if (vid == 3) { u = u0; y = 0.9; }
|
||||
else if (vid == 4) { u = u1; y = -0.9; }
|
||||
else if (vid == 5) { u = u1; y = 0.9; }
|
||||
|
||||
vUV = vec2(u, (0.9 - y) / 1.8);
|
||||
|
||||
// Nominal screen width = 3.2 (-1.6 to +1.6)
|
||||
const float kWidth = 3.2;
|
||||
float curv = clamp(pc.stereo.w, 0.0, 1.0);
|
||||
|
||||
float x = (u - 0.5) * kWidth;
|
||||
float z = 0.0;
|
||||
if (curv > 0.005) {
|
||||
// Pure cylindrical arc of constant arc length kWidth.
|
||||
// At curv=1.0, total wrap angle is ~100 degrees (1.75 rad) around the viewer.
|
||||
float totalAngle = curv * 1.75;
|
||||
float radius = kWidth / totalAngle;
|
||||
float theta = (u - 0.5) * totalAngle;
|
||||
x = radius * sin(theta);
|
||||
z = radius * (1.0 - cos(theta));
|
||||
}
|
||||
|
||||
gl_Position = pc.mvp * vec4(x, y, z, 1.0);
|
||||
}
|
||||
|
||||
@@ -12,10 +12,35 @@ layout(push_constant) uniform Push {
|
||||
layout(location = 0) in vec2 vUV;
|
||||
layout(location = 0) out vec4 outColor;
|
||||
|
||||
vec3 toLinear(vec3 srgb) {
|
||||
return pow(max(srgb, vec3(0.0)), vec3(2.2));
|
||||
}
|
||||
|
||||
void main() {
|
||||
// Signed-distance text: a crisp ~1px edge in screen space so it stays sharp
|
||||
// at any size (a coverage-bitmap fallback also reads acceptably here).
|
||||
// Stable screen-space signed-distance field text rendering:
|
||||
// Screen-space derivatives of texture coordinates (vUV) provide a smooth,
|
||||
// noise-free measure of font texels per screen pixel across the glyph quad,
|
||||
// completely eliminating fragment-derivative jitter during head movement in VR.
|
||||
vec2 texSize = vec2(textureSize(fontAtlas, 0));
|
||||
vec2 ddx = dFdx(vUV) * texSize;
|
||||
vec2 ddy = dFdy(vUV) * texSize;
|
||||
// RMS font-texel footprint per screen pixel (rotation and perspective invariant)
|
||||
float fontPixelsPerScreenPixel = 0.70710678 * sqrt(dot(ddx, ddx) + dot(ddy, ddy));
|
||||
|
||||
float d = texture(fontAtlas, vUV).r;
|
||||
float a = clamp((d - 0.5) / max(fwidth(d), 0.0001) + 0.5, 0.0, 1.0);
|
||||
outColor = vec4(pc.color.rgb, pc.color.a * a);
|
||||
// FreeType SDF: spread = 8 font pixels, contour zero-crossing is 128 / 255.0 = 0.50196078.
|
||||
// Range [-8, +8] font pixels is mapped across [0, 1].
|
||||
float distInFontPixels = (d - 0.50196078) * 16.0;
|
||||
|
||||
// Convert distance to screen pixels, adding a subtle stem-darkening bias (+0.14 screen px)
|
||||
// to preserve thin strokes and legibility against high-contrast dark VR backgrounds.
|
||||
float distInScreenPixels = (distInFontPixels / max(fontPixelsPerScreenPixel, 1e-4)) + 0.14;
|
||||
|
||||
// Smooth Hermite anti-aliasing across 1.4 screen pixels (smooth transition matches VR subpixel PSF)
|
||||
float a = smoothstep(-0.70, 0.70, distInScreenPixels);
|
||||
if (a <= 0.002) {
|
||||
discard;
|
||||
}
|
||||
|
||||
outColor = vec4(toLinear(pc.color.rgb), pc.color.a * a);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
#version 450
|
||||
|
||||
layout(binding = 0) uniform sampler2D thumbTex;
|
||||
|
||||
layout(push_constant) uniform Push {
|
||||
mat4 mvp;
|
||||
vec4 color;
|
||||
vec4 rect; // x, y (bottom-left), w, h in panel-local units
|
||||
vec4 uv; // u0, v0, u1, v1
|
||||
} pc;
|
||||
|
||||
layout(location = 0) in vec2 vUV;
|
||||
layout(location = 0) out vec4 outColor;
|
||||
|
||||
void main() {
|
||||
vec4 col = texture(thumbTex, vUV);
|
||||
|
||||
// Exact metric rounded rectangle SDF matching panel styling with screen-space AA
|
||||
vec2 p = abs(vUV - 0.5) * pc.rect.zw;
|
||||
vec2 b = 0.5 * pc.rect.zw - vec2(0.018);
|
||||
vec2 q = max(p - b, 0.0);
|
||||
float dist = length(q) - 0.018;
|
||||
vec2 dpx = dFdx(p);
|
||||
vec2 dpy = dFdy(p);
|
||||
float pixSize = 0.70710678 * sqrt(dot(dpx, dpx) + dot(dpy, dpy));
|
||||
float aa = max(pixSize * 1.4, 0.001);
|
||||
float alpha = 1.0 - smoothstep(-aa, aa, dist);
|
||||
if (alpha <= 0.002) {
|
||||
discard;
|
||||
}
|
||||
|
||||
// Thumbnail pixels are sRGB; convert to linear for the VK_FORMAT_*_SRGB framebuffer
|
||||
vec3 linRgb = pow(max(col.rgb, vec3(0.0)), vec3(2.2));
|
||||
outColor = vec4(linRgb * pc.color.rgb, col.a * pc.color.a * alpha);
|
||||
}
|
||||
@@ -122,24 +122,76 @@ void main() {
|
||||
vec2(0.36, 0.30), vec2(0.0, -0.28)) - 0.03;
|
||||
float base = sdRoundedBox(p - vec2(0.0, -0.62), vec2(0.52, 0.08), 0.03);
|
||||
d = min(min(shaft, head), base);
|
||||
} else if (shape == 13) {
|
||||
// Shape 13: List / queue (three bars with leading dots) for the
|
||||
// downloads window, so it is distinct from the plain download arrow.
|
||||
float b1 = sdRoundedBox(p - vec2(0.10, 0.46), vec2(0.40, 0.07), 0.05);
|
||||
float b2 = sdRoundedBox(p - vec2(0.10, 0.00), vec2(0.40, 0.07), 0.05);
|
||||
float b3 = sdRoundedBox(p - vec2(0.10, -0.46), vec2(0.40, 0.07), 0.05);
|
||||
float p1 = sdRoundedBox(p - vec2(-0.50, 0.46), vec2(0.10, 0.10), 0.05);
|
||||
float p2 = sdRoundedBox(p - vec2(-0.50, 0.00), vec2(0.10, 0.10), 0.05);
|
||||
float p3 = sdRoundedBox(p - vec2(-0.50, -0.46), vec2(0.10, 0.10), 0.05);
|
||||
d = min(min(min(b1, b2), min(b3, p1)), min(p2, p3));
|
||||
} else if (shape == 14) {
|
||||
// Shape 14: Hero Glow Ring (circular outline for primary buttons)
|
||||
float r = length(p);
|
||||
d = abs(r - 0.72) - 0.08;
|
||||
} else if (shape == 15) {
|
||||
// Shape 15: Audio Speaker icon (cone + sound wave)
|
||||
float box = sdRoundedBox(p - vec2(-0.32, 0.0), vec2(0.12, 0.18), 0.03);
|
||||
float cone = sdTriangle(p, vec2(-0.20, 0.18), vec2(0.16, 0.45), vec2(-0.20, -0.18)) - 0.02;
|
||||
float cone2 = sdTriangle(p, vec2(-0.20, -0.18), vec2(0.16, -0.45), vec2(0.16, 0.45)) - 0.02;
|
||||
float rArc = length(p - vec2(-0.05, 0.0));
|
||||
float arc1 = max(abs(rArc - 0.46) - 0.06, -p.x + 0.10);
|
||||
float arc2 = max(abs(rArc - 0.68) - 0.06, -p.x + 0.22);
|
||||
d = min(min(box, min(cone, cone2)), min(arc1, arc2));
|
||||
} else if (shape == 16) {
|
||||
// Shape 16: VR Headset / Display goggles
|
||||
float gBody = sdRoundedBox(p, vec2(0.55, 0.30), 0.14);
|
||||
float nose = sdTriangle(p - vec2(0.0, -0.30), vec2(-0.16, -0.08), vec2(0.16, -0.08), vec2(0.0, 0.12)) - 0.04;
|
||||
float strapL = sdRoundedBox(p - vec2(-0.62, 0.0), vec2(0.08, 0.10), 0.03);
|
||||
float strapR = sdRoundedBox(p - vec2(0.62, 0.0), vec2(0.08, 0.10), 0.03);
|
||||
d = max(min(gBody, min(strapL, strapR)), -nose);
|
||||
} else if (shape == 17) {
|
||||
// Shape 17: Curved Cinema Display icon
|
||||
float rScreen = length(p - vec2(0.0, 1.3));
|
||||
float arcDisp = abs(rScreen - 1.62) - 0.10;
|
||||
float cutX = abs(p.x) - 0.58;
|
||||
float cutY = abs(p.y) - 0.32;
|
||||
d = max(arcDisp, max(cutX, cutY));
|
||||
} else if (shape == 18) {
|
||||
// Shape 18: Chevron (>) for breadcrumbs & pagination
|
||||
float c1 = sdSegment(p, vec2(-0.22, 0.40), vec2(0.24, 0.0)) - 0.08;
|
||||
float c2 = sdSegment(p, vec2(0.24, 0.0), vec2(-0.22, -0.40)) - 0.08;
|
||||
d = min(c1, c2);
|
||||
} else if (shape == 19) {
|
||||
// Shape 19: Home / Library icon
|
||||
float roof = sdTriangle(p - vec2(0.0, 0.12), vec2(-0.52, 0.0), vec2(0.52, 0.0), vec2(0.0, 0.42)) - 0.04;
|
||||
float baseH = sdRoundedBox(p - vec2(0.0, -0.20), vec2(0.35, 0.26), 0.04);
|
||||
d = min(roof, baseH);
|
||||
} else {
|
||||
d = sdRoundedBox(p, vec2(1.0), 0.0);
|
||||
}
|
||||
|
||||
float aa = fwidth(d);
|
||||
if (aa < 1e-4) aa = 0.015;
|
||||
float fillAlpha = 1.0 - smoothstep(-aa * 0.5, aa * 0.5, d);
|
||||
// Exact screen-space gradient magnitude of the distance field along the contour normal.
|
||||
// Produces a crisp, razor-sharp 1.5 screen-pixel anti-aliased outline without crawling.
|
||||
float gradLen = length(vec2(dFdx(d), dFdy(d)));
|
||||
float aa = max(gradLen * 0.75, 0.0005);
|
||||
float fillAlpha = 1.0 - smoothstep(-aa, aa, d);
|
||||
|
||||
if (fillAlpha <= 0.0) {
|
||||
if (fillAlpha <= 0.002) {
|
||||
discard;
|
||||
}
|
||||
|
||||
vec4 finalColor = pc.color;
|
||||
vec4 fillLin = vec4(pow(max(pc.color.rgb, vec3(0.0)), vec3(2.2)), pc.color.a);
|
||||
vec4 finalColor = fillLin;
|
||||
if (hasBorder) {
|
||||
// Crisp, razor-sharp border highlight line matching requested thickness
|
||||
float borderWidth = pc.params.z;
|
||||
float innerD = d + borderWidth;
|
||||
float borderAlpha = 1.0 - smoothstep(-aa * 0.5, aa * 0.5, innerD);
|
||||
finalColor = mix(pc.borderColor, pc.color, borderAlpha);
|
||||
float borderAlpha = 1.0 - smoothstep(-aa, aa, innerD);
|
||||
vec4 bordLin = vec4(pow(max(pc.borderColor.rgb, vec3(0.0)), vec3(2.2)), pc.borderColor.a);
|
||||
finalColor = mix(bordLin, fillLin, borderAlpha);
|
||||
}
|
||||
|
||||
finalColor.a *= fillAlpha;
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
#ifdef PIXELVR_HAVE_FREETYPE
|
||||
#include "ui/browser.hpp"
|
||||
#include "ui/thumbnail_cache.hpp"
|
||||
#endif
|
||||
|
||||
#include "ui/settings_menu.hpp"
|
||||
@@ -50,6 +51,8 @@ private:
|
||||
bool seekStepArmed_ = true; // right-stick +/-30s debounce
|
||||
bool navArmed_ = true; // menu nav: armed for the next stick press
|
||||
std::chrono::steady_clock::time_point navNextRepeat_{}; // nav auto-repeat
|
||||
bool navArmedX_ = true; // horizontal grid nav
|
||||
std::chrono::steady_clock::time_point navNextRepeatX_{};
|
||||
bool scrubbing_ = false; // dragging the timeline / stick-scrubbing
|
||||
double scrubTarget_ = 0.0; // pending scrub position (seconds)
|
||||
std::chrono::steady_clock::time_point perfLastLog_{};
|
||||
@@ -58,12 +61,12 @@ private:
|
||||
|
||||
// Place the screen in front of the user on the first valid head pose.
|
||||
bool didInitialRecenter_ = false;
|
||||
bool controllerModelLoaded_ = false; // real glTF model uploaded once
|
||||
// Local grip->render-model-space correction so the model sits exactly where
|
||||
// the runtime renders it (derived from the fetched model's owner hand).
|
||||
bool modelCorrValid_ = false;
|
||||
float modelCorrPos_[3] = {0.0f, 0.0f, 0.0f};
|
||||
float modelCorrQuat_[4] = {0.0f, 0.0f, 0.0f, 1.0f};
|
||||
bool controllerModelLoaded_[2] = {false, false}; // glTF model uploaded per hand
|
||||
// Local grip->render-model-space correction per hand so each model sits
|
||||
// exactly where the runtime renders it.
|
||||
bool modelCorrValid_[2] = {false, false};
|
||||
float modelCorrPos_[2][3] = {{0.0f, 0.0f, 0.0f}, {0.0f, 0.0f, 0.0f}};
|
||||
float modelCorrQuat_[2][4] = {{0.0f, 0.0f, 0.0f, 1.0f}, {0.0f, 0.0f, 0.0f, 1.0f}};
|
||||
// Background download status line: shown while active and briefly after a
|
||||
// download finishes (so success/failure is visible with the browser closed).
|
||||
std::chrono::steady_clock::time_point downloadToast_{};
|
||||
@@ -74,6 +77,9 @@ private:
|
||||
bool pendingDlFresh_ = false; // suppress the same-frame A press
|
||||
bool downloadsWindow_ = false; // downloads list window toggled from the bar
|
||||
bool dlWasActive_ = false; // edge detect: auto-open the window on start
|
||||
bool hudDismissed_ = false; // manually dismissed player controls / HUD
|
||||
std::chrono::steady_clock::time_point hudDismissTime_{}; // timestamp of manual dismissal
|
||||
void dismissHud(const XrInputState& in, std::chrono::steady_clock::time_point nowT);
|
||||
// Low-pass smoothed model pose per hand (the raw hand pose jitters; the
|
||||
// system's own controller model is visibly smoothed).
|
||||
bool modelSmoothInit_[2] = {false, false};
|
||||
@@ -92,9 +98,33 @@ private:
|
||||
void updateStats();
|
||||
// Menu nav stick auto-repeat: -1/+1 to step (initial press + hold), else 0.
|
||||
int navStep(float axisY, std::chrono::steady_clock::time_point now);
|
||||
int navStepX(float axisX, std::chrono::steady_clock::time_point now);
|
||||
void performVaultLock();
|
||||
VulkanRenderer::ActionMenuData actionMenu_{};
|
||||
void openActionMenu();
|
||||
|
||||
struct SmbModalState {
|
||||
bool active = false;
|
||||
bool isNew = true;
|
||||
std::string origHost;
|
||||
std::string origShare;
|
||||
std::string host;
|
||||
std::string share;
|
||||
bool anon = false;
|
||||
std::string user;
|
||||
std::string pass;
|
||||
std::string workgroup = "WORKGROUP";
|
||||
std::string label;
|
||||
enum class Field { None, Host, Share, User, Pass, Workgroup, Label } editingField = Field::None;
|
||||
} smbModal_{};
|
||||
void openSmbModal(bool isNew, const std::string& smbPath = "");
|
||||
void updateSmbModalMenu();
|
||||
void saveSmbModal();
|
||||
void deleteSmbModal();
|
||||
|
||||
#ifdef PIXELVR_HAVE_FREETYPE
|
||||
Browser browser_;
|
||||
ThumbnailCache thumbnailCache_;
|
||||
#endif
|
||||
|
||||
SettingsMenu settings_;
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
#include "app/vault_manager.hpp"
|
||||
#include "util/logging.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
|
||||
namespace pixelvr {
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
namespace {
|
||||
|
||||
std::string getVaultConfigPath() {
|
||||
#ifdef _WIN32
|
||||
const char* appData = std::getenv("APPDATA");
|
||||
if (appData) return std::string(appData) + "/PixelVR/vault.conf";
|
||||
#else
|
||||
const char* home = std::getenv("HOME");
|
||||
if (home) return std::string(home) + "/.config/pixelvr/vault.conf";
|
||||
#endif
|
||||
return "pixelvr_vault.conf";
|
||||
}
|
||||
|
||||
const char* btnName(int b) {
|
||||
switch (b) {
|
||||
case XrInputState::BtnA: return "A";
|
||||
case XrInputState::BtnB: return "B";
|
||||
case XrInputState::BtnX: return "X";
|
||||
case XrInputState::BtnY: return "Y";
|
||||
case XrInputState::BtnMenu: return "Menu";
|
||||
case XrInputState::BtnView: return "View";
|
||||
case XrInputState::BtnBumper: return "Bumper";
|
||||
case XrInputState::BtnThumb: return "Thumb";
|
||||
case XrInputState::BtnGrip: return "Grip";
|
||||
default: return "?";
|
||||
}
|
||||
}
|
||||
|
||||
static uint64_t fnv1a64(const std::string& str) {
|
||||
uint64_t hash = 14695981039346656037ULL;
|
||||
for (char c : str) {
|
||||
hash ^= static_cast<uint8_t>(c);
|
||||
hash *= 1099511628211ULL;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
VaultManager& VaultManager::instance() {
|
||||
static VaultManager s_instance;
|
||||
return s_instance;
|
||||
}
|
||||
|
||||
VaultManager::VaultManager() {
|
||||
const char* home = std::getenv("HOME");
|
||||
if (home != nullptr) {
|
||||
vaultDir_ = std::string(home) + "/.pixelvr_vault";
|
||||
} else {
|
||||
vaultDir_ = "/home/steamos/.pixelvr_vault";
|
||||
}
|
||||
thumbsDir_ = vaultDir_ + "/.thumbs";
|
||||
|
||||
// Default sequence: A -> B -> B -> A -> X -> Y
|
||||
comboSequence_ = {
|
||||
XrInputState::BtnA,
|
||||
XrInputState::BtnB,
|
||||
XrInputState::BtnB,
|
||||
XrInputState::BtnA,
|
||||
XrInputState::BtnX,
|
||||
XrInputState::BtnY
|
||||
};
|
||||
|
||||
loadConfig();
|
||||
}
|
||||
|
||||
void VaultManager::ensureDirectories() {
|
||||
std::error_code ec;
|
||||
fs::create_directories(vaultDir_, ec);
|
||||
if (!ec) {
|
||||
fs::permissions(vaultDir_, fs::perms::owner_all, fs::perm_options::replace, ec);
|
||||
}
|
||||
fs::create_directories(thumbsDir_, ec);
|
||||
if (!ec) {
|
||||
fs::permissions(thumbsDir_, fs::perms::owner_all, fs::perm_options::replace, ec);
|
||||
}
|
||||
}
|
||||
|
||||
bool VaultManager::isVaultPath(const std::string& path) const {
|
||||
if (path.empty() || vaultDir_.empty()) return false;
|
||||
return path.rfind(vaultDir_, 0) == 0;
|
||||
}
|
||||
|
||||
void VaultManager::lock() {
|
||||
if (!unlocked_) return;
|
||||
unlocked_ = false;
|
||||
comboIndex_ = 0;
|
||||
headsetLostTime_ = {};
|
||||
isRecordingCombo_ = false;
|
||||
recordingBuffer_.clear();
|
||||
PIXELVR_LOG_INFO("Vault locked");
|
||||
}
|
||||
|
||||
void VaultManager::unlock() {
|
||||
ensureDirectories();
|
||||
unlocked_ = true;
|
||||
comboIndex_ = 0;
|
||||
headsetLostTime_ = {};
|
||||
lastActivity_ = std::chrono::steady_clock::now();
|
||||
PIXELVR_LOG_INFO("Vault unlocked at %s", vaultDir_.c_str());
|
||||
}
|
||||
|
||||
VaultManager::ComboResult VaultManager::feedInput(int button, bool gripFullPressHeld,
|
||||
std::chrono::steady_clock::time_point now) {
|
||||
if (!gripFullPressHeld) {
|
||||
comboIndex_ = 0;
|
||||
return ComboResult::None;
|
||||
}
|
||||
|
||||
// Only face buttons participate in the cheat code
|
||||
if (button != XrInputState::BtnA && button != XrInputState::BtnB &&
|
||||
button != XrInputState::BtnX && button != XrInputState::BtnY) {
|
||||
return ComboResult::None;
|
||||
}
|
||||
|
||||
using namespace std::chrono_literals;
|
||||
if (comboIndex_ > 0 && (now - lastComboInput_) > 2500ms) {
|
||||
comboIndex_ = 0;
|
||||
}
|
||||
lastComboInput_ = now;
|
||||
|
||||
if (comboIndex_ < comboSequence_.size() && button == comboSequence_[comboIndex_]) {
|
||||
comboIndex_++;
|
||||
if (comboIndex_ >= comboSequence_.size()) {
|
||||
comboIndex_ = 0;
|
||||
if (unlocked_) {
|
||||
lock();
|
||||
return ComboResult::Locked;
|
||||
} else {
|
||||
unlock();
|
||||
return ComboResult::Unlocked;
|
||||
}
|
||||
}
|
||||
return ComboResult::StepMatched;
|
||||
} else {
|
||||
// Wrong button pressed while grip was held
|
||||
comboIndex_ = 0;
|
||||
return ComboResult::Failed;
|
||||
}
|
||||
}
|
||||
|
||||
void VaultManager::recordActivity(std::chrono::steady_clock::time_point now) {
|
||||
lastActivity_ = now;
|
||||
}
|
||||
|
||||
bool VaultManager::tick(std::chrono::steady_clock::time_point now, bool headsetFocused) {
|
||||
if (!unlocked_) return false;
|
||||
|
||||
// Guard 1: Headset removed / focus lost (debounced with 3.0s threshold to avoid momentary tracking glitches)
|
||||
if (autoLockHeadsetOff_) {
|
||||
if (!headsetFocused) {
|
||||
if (headsetLostTime_.time_since_epoch().count() == 0) {
|
||||
headsetLostTime_ = now;
|
||||
} else {
|
||||
auto elapsedMs = std::chrono::duration_cast<std::chrono::milliseconds>(now - headsetLostTime_).count();
|
||||
if (elapsedMs >= 3000) {
|
||||
PIXELVR_LOG_INFO("Auto-locking vault: headset removed or session lost focus for %lld ms",
|
||||
static_cast<long long>(elapsedMs));
|
||||
headsetLostTime_ = {};
|
||||
lock();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
headsetLostTime_ = {};
|
||||
}
|
||||
} else {
|
||||
headsetLostTime_ = {};
|
||||
}
|
||||
|
||||
// Guard 2: Inactivity timeout (bypassed if autoLockTimeoutSec_ <= 0)
|
||||
if (autoLockTimeoutSec_ > 0 && lastActivity_.time_since_epoch().count() > 0) {
|
||||
auto elapsed = std::chrono::duration_cast<std::chrono::seconds>(now - lastActivity_).count();
|
||||
if (elapsed >= autoLockTimeoutSec_) {
|
||||
PIXELVR_LOG_INFO("Auto-locking vault: inactivity timeout (%llds >= %ds)",
|
||||
static_cast<long long>(elapsed), autoLockTimeoutSec_);
|
||||
lock();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
void VaultManager::cleanPublicThumbs(const std::string& path) {
|
||||
const char* home = std::getenv("HOME");
|
||||
if (!home) return;
|
||||
std::error_code ec;
|
||||
std::string pubThumbDir = std::string(home) + "/.cache/pixelvr/thumbs";
|
||||
if (!fs::exists(pubThumbDir, ec)) return;
|
||||
|
||||
auto removeThumbFor = [&](const std::string& fpath) {
|
||||
const std::string baseHash = std::to_string(fnv1a64(fpath));
|
||||
std::error_code e;
|
||||
fs::remove(pubThumbDir + "/" + baseHash + ".rgba", e);
|
||||
e.clear();
|
||||
fs::remove(pubThumbDir + "/" + baseHash + ".meta", e);
|
||||
};
|
||||
|
||||
if (fs::is_directory(path, ec)) {
|
||||
for (const auto& it : fs::recursive_directory_iterator(path, ec)) {
|
||||
if (it.is_regular_file()) {
|
||||
removeThumbFor(it.path().string());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
removeThumbFor(path);
|
||||
}
|
||||
}
|
||||
|
||||
std::string VaultManager::moveFileToVault(const std::string& srcPath, std::string& outNewPath) {
|
||||
if (!unlocked_) {
|
||||
return "Vault is locked";
|
||||
}
|
||||
if (srcPath.empty()) {
|
||||
return "Source path is empty";
|
||||
}
|
||||
std::error_code ec;
|
||||
if (!fs::exists(srcPath, ec)) {
|
||||
return "Path does not exist";
|
||||
}
|
||||
|
||||
ensureDirectories();
|
||||
|
||||
fs::path src(srcPath);
|
||||
const bool isDir = fs::is_directory(src, ec);
|
||||
std::string stem = src.stem().string();
|
||||
std::string ext = isDir ? "" : src.extension().string();
|
||||
std::string targetFilename = src.filename().string();
|
||||
fs::path dest = fs::path(vaultDir_) / targetFilename;
|
||||
|
||||
// Prevent collision by appending _1, _2, etc.
|
||||
int counter = 1;
|
||||
while (fs::exists(dest, ec)) {
|
||||
targetFilename = isDir ? (stem + "_" + std::to_string(counter++))
|
||||
: (stem + "_" + std::to_string(counter++) + ext);
|
||||
dest = fs::path(vaultDir_) / targetFilename;
|
||||
}
|
||||
|
||||
// Try rename first
|
||||
fs::rename(src, dest, ec);
|
||||
if (ec) {
|
||||
ec.clear();
|
||||
if (isDir) {
|
||||
fs::copy(src, dest, fs::copy_options::recursive, ec);
|
||||
if (ec) {
|
||||
return "Failed to copy directory: " + ec.message();
|
||||
}
|
||||
fs::remove_all(src, ec);
|
||||
} else {
|
||||
fs::copy_file(src, dest, fs::copy_options::overwrite_existing, ec);
|
||||
if (ec) {
|
||||
return "Failed to copy file: " + ec.message();
|
||||
}
|
||||
fs::remove(src, ec);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure 0700 permissions
|
||||
ec.clear();
|
||||
fs::permissions(dest, fs::perms::owner_all, fs::perm_options::replace, ec);
|
||||
if (isDir) {
|
||||
for (const auto& it : fs::recursive_directory_iterator(dest, ec)) {
|
||||
std::error_code pe;
|
||||
fs::permissions(it.path(), fs::perms::owner_all, fs::perm_options::replace, pe);
|
||||
}
|
||||
}
|
||||
|
||||
outNewPath = dest.string();
|
||||
PIXELVR_LOG_INFO("Moved into vault: %s -> %s", srcPath.c_str(), outNewPath.c_str());
|
||||
|
||||
// Clean up any public thumbnails that existed for srcPath
|
||||
cleanPublicThumbs(srcPath);
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
void VaultManager::setAutoLockHeadsetOff(bool enable) {
|
||||
autoLockHeadsetOff_ = enable;
|
||||
saveConfig();
|
||||
}
|
||||
|
||||
void VaultManager::setAutoLockTimeoutSec(int sec) {
|
||||
autoLockTimeoutSec_ = sec;
|
||||
saveConfig();
|
||||
}
|
||||
|
||||
void VaultManager::setMoveButton(int btn) {
|
||||
moveButton_ = btn;
|
||||
saveConfig();
|
||||
}
|
||||
|
||||
std::string VaultManager::comboString() const {
|
||||
if (comboSequence_.empty()) return "None";
|
||||
std::string s;
|
||||
for (std::size_t i = 0; i < comboSequence_.size(); ++i) {
|
||||
if (i > 0) s += " ";
|
||||
s += btnName(comboSequence_[i]);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
void VaultManager::startRecordingCombo() {
|
||||
isRecordingCombo_ = true;
|
||||
recordingBuffer_.clear();
|
||||
}
|
||||
|
||||
void VaultManager::cancelRecordingCombo() {
|
||||
isRecordingCombo_ = false;
|
||||
recordingBuffer_.clear();
|
||||
}
|
||||
|
||||
bool VaultManager::feedRecordButton(int button) {
|
||||
if (!isRecordingCombo_) return false;
|
||||
if (button != XrInputState::BtnA && button != XrInputState::BtnB &&
|
||||
button != XrInputState::BtnX && button != XrInputState::BtnY) {
|
||||
return false;
|
||||
}
|
||||
recordingBuffer_.push_back(button);
|
||||
if (recordingBuffer_.size() >= 6) {
|
||||
comboSequence_ = recordingBuffer_;
|
||||
isRecordingCombo_ = false;
|
||||
recordingBuffer_.clear();
|
||||
saveConfig();
|
||||
return true; // Finished recording 6-step combo
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void VaultManager::loadConfig() {
|
||||
const std::string path = getVaultConfigPath();
|
||||
std::ifstream file(path);
|
||||
if (!file.is_open()) return;
|
||||
|
||||
std::string line;
|
||||
while (std::getline(file, line)) {
|
||||
auto eq = line.find('=');
|
||||
if (eq == std::string::npos) continue;
|
||||
std::string key = line.substr(0, eq);
|
||||
std::string val = line.substr(eq + 1);
|
||||
|
||||
if (key == "auto_lock_headset_off") {
|
||||
autoLockHeadsetOff_ = (val == "1");
|
||||
} else if (key == "auto_lock_timeout_sec") {
|
||||
try { autoLockTimeoutSec_ = std::stoi(val); } catch (...) {}
|
||||
} else if (key == "move_button") {
|
||||
try { moveButton_ = std::stoi(val); } catch (...) {}
|
||||
} else if (key == "combo") {
|
||||
std::vector<int> loaded;
|
||||
std::stringstream ss(val);
|
||||
std::string item;
|
||||
while (std::getline(ss, item, ',')) {
|
||||
try { loaded.push_back(std::stoi(item)); } catch (...) {}
|
||||
}
|
||||
if (loaded.size() >= 4 && loaded.size() <= 8) {
|
||||
comboSequence_ = loaded;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void VaultManager::saveConfig() const {
|
||||
const std::string path = getVaultConfigPath();
|
||||
try {
|
||||
fs::path p(path);
|
||||
if (p.has_parent_path()) {
|
||||
fs::create_directories(p.parent_path());
|
||||
}
|
||||
std::ofstream file(path);
|
||||
if (file.is_open()) {
|
||||
file << "auto_lock_headset_off=" << (autoLockHeadsetOff_ ? 1 : 0) << "\n";
|
||||
file << "auto_lock_timeout_sec=" << autoLockTimeoutSec_ << "\n";
|
||||
file << "move_button=" << moveButton_ << "\n";
|
||||
file << "combo=";
|
||||
for (std::size_t i = 0; i < comboSequence_.size(); ++i) {
|
||||
if (i > 0) file << ",";
|
||||
file << comboSequence_[i];
|
||||
}
|
||||
file << "\n";
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
PIXELVR_LOG_WARN("Could not save vault settings: %s", e.what());
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace pixelvr
|
||||
@@ -0,0 +1,102 @@
|
||||
#pragma once
|
||||
|
||||
#include "xr/xr_context.hpp"
|
||||
|
||||
#include <chrono>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace pixelvr {
|
||||
|
||||
class VaultManager {
|
||||
public:
|
||||
enum class ComboResult {
|
||||
None,
|
||||
StepMatched,
|
||||
Unlocked,
|
||||
Locked,
|
||||
Failed
|
||||
};
|
||||
|
||||
static VaultManager& instance();
|
||||
|
||||
bool isUnlocked() const { return unlocked_; }
|
||||
void lock();
|
||||
void unlock();
|
||||
|
||||
// Process button input during frame loop.
|
||||
// If gripFullPressHeld is true, face buttons are consumed for the combo and
|
||||
// caller should suppress them from normal UI.
|
||||
ComboResult feedInput(int button, bool gripFullPressHeld,
|
||||
std::chrono::steady_clock::time_point now);
|
||||
|
||||
// Call when any user activity occurs (sticks, triggers, buttons)
|
||||
void recordActivity(std::chrono::steady_clock::time_point now);
|
||||
|
||||
// Frame tick: checks inactivity timeout and headset focus loss.
|
||||
// Returns true if the vault was locked as a result of the tick.
|
||||
bool tick(std::chrono::steady_clock::time_point now, bool headsetFocused);
|
||||
|
||||
// Storage paths
|
||||
const std::string& vaultDir() const { return vaultDir_; }
|
||||
const std::string& thumbsDir() const { return thumbsDir_; }
|
||||
bool isVaultPath(const std::string& path) const;
|
||||
void ensureDirectories();
|
||||
|
||||
// Move file or directory into vault (local paths only).
|
||||
// Returns empty string on success, or error message on failure.
|
||||
std::string moveFileToVault(const std::string& srcPath, std::string& outNewPath);
|
||||
|
||||
// Clean up cached public thumbnails for a file or directory
|
||||
void cleanPublicThumbs(const std::string& path);
|
||||
|
||||
// Settings
|
||||
bool autoLockHeadsetOff() const { return autoLockHeadsetOff_; }
|
||||
void setAutoLockHeadsetOff(bool enable);
|
||||
|
||||
int autoLockTimeoutSec() const { return autoLockTimeoutSec_; }
|
||||
void setAutoLockTimeoutSec(int sec);
|
||||
|
||||
int moveButton() const { return moveButton_; }
|
||||
void setMoveButton(int btn);
|
||||
|
||||
const std::vector<int>& comboSequence() const { return comboSequence_; }
|
||||
std::string comboString() const;
|
||||
|
||||
// Custom combo recording
|
||||
bool isRecordingCombo() const { return isRecordingCombo_; }
|
||||
void startRecordingCombo();
|
||||
void cancelRecordingCombo();
|
||||
bool feedRecordButton(int button);
|
||||
int recordedCount() const { return static_cast<int>(recordingBuffer_.size()); }
|
||||
|
||||
void loadConfig();
|
||||
void saveConfig() const;
|
||||
|
||||
private:
|
||||
VaultManager();
|
||||
~VaultManager() = default;
|
||||
|
||||
bool unlocked_ = false;
|
||||
std::string vaultDir_;
|
||||
std::string thumbsDir_;
|
||||
|
||||
// Combo state machine
|
||||
std::vector<int> comboSequence_;
|
||||
std::size_t comboIndex_ = 0;
|
||||
std::chrono::steady_clock::time_point lastComboInput_{};
|
||||
|
||||
// Recording custom combo
|
||||
bool isRecordingCombo_ = false;
|
||||
std::vector<int> recordingBuffer_;
|
||||
|
||||
// Settings
|
||||
bool autoLockHeadsetOff_ = true;
|
||||
int autoLockTimeoutSec_ = 0; // Disabled by default (headset face detection active)
|
||||
int moveButton_ = XrInputState::BtnY;
|
||||
|
||||
std::chrono::steady_clock::time_point lastActivity_{};
|
||||
std::chrono::steady_clock::time_point headsetLostTime_{};
|
||||
};
|
||||
|
||||
} // namespace pixelvr
|
||||
@@ -5,6 +5,7 @@
|
||||
#ifdef PIXELVR_HAVE_FREETYPE
|
||||
#include <ft2build.h>
|
||||
#include FT_FREETYPE_H
|
||||
#include FT_MODULE_H
|
||||
#endif
|
||||
|
||||
namespace pixelvr {
|
||||
@@ -17,6 +18,15 @@ FontAtlas buildFontAtlas(const char* ttfPath, int pixelHeight) {
|
||||
PIXELVR_LOG_ERROR("FreeType: init failed");
|
||||
return atlas;
|
||||
}
|
||||
// Expand the distance field ramp from FreeType's default 2px to 8px.
|
||||
// At typical VR UI viewing distances, 8 font pixels produces a smooth,
|
||||
// noise-free gradient spanning multiple screen pixels, completely eliminating
|
||||
// edge shimmering during head movement.
|
||||
constexpr FT_UInt kSpread = 8;
|
||||
FT_Property_Set(lib, "sdf", "spread", &kSpread);
|
||||
FT_Property_Set(lib, "bsdf", "spread", &kSpread);
|
||||
atlas.spread = static_cast<float>(kSpread);
|
||||
|
||||
FT_Face face = nullptr;
|
||||
if (FT_New_Face(lib, ttfPath, 0, &face) != 0) {
|
||||
PIXELVR_LOG_ERROR("FreeType: cannot open %s", ttfPath);
|
||||
@@ -26,7 +36,7 @@ FontAtlas buildFontAtlas(const char* ttfPath, int pixelHeight) {
|
||||
FT_Set_Pixel_Sizes(face, 0, static_cast<FT_UInt>(pixelHeight));
|
||||
|
||||
constexpr int kFirst = 32;
|
||||
constexpr int kLast = 126;
|
||||
constexpr int kLast = 127;
|
||||
constexpr int kCols = 16;
|
||||
const int count = kLast - kFirst + 1;
|
||||
const int rows = (count + kCols - 1) / kCols;
|
||||
@@ -39,8 +49,13 @@ FontAtlas buildFontAtlas(const char* ttfPath, int pixelHeight) {
|
||||
atlas.ascent = face->size->metrics.ascender / 64.0f;
|
||||
|
||||
for (int ch = kFirst; ch <= kLast; ++ch) {
|
||||
if (FT_Load_Char(face, static_cast<FT_ULong>(ch), FT_LOAD_DEFAULT) != 0) {
|
||||
continue;
|
||||
FT_ULong codepoint = (ch == 127) ? 0x2022UL : static_cast<FT_ULong>(ch);
|
||||
if (FT_Load_Char(face, codepoint, FT_LOAD_DEFAULT) != 0) {
|
||||
if (ch == 127 && FT_Load_Char(face, 0x00B7UL, FT_LOAD_DEFAULT) != 0) {
|
||||
continue;
|
||||
} else if (ch != 127) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const FT_GlyphSlot g = face->glyph;
|
||||
// Render a signed-distance field (crisp at any VR distance); fall back to
|
||||
|
||||
@@ -22,6 +22,7 @@ struct FontAtlas {
|
||||
float lineHeight = 0.0f;
|
||||
float ascent = 0.0f;
|
||||
bool sdf = false; // true = signed-distance field (crisp at scale)
|
||||
float spread = 8.0f; // SDF spread distance in font pixels (contour zero-crossing at 128)
|
||||
bool ok = false;
|
||||
};
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include "xr/openxr_headers.hpp"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
@@ -13,6 +14,7 @@ namespace pixelvr {
|
||||
class XrContext;
|
||||
struct ViewSwapchain;
|
||||
struct DrmVideoFrame;
|
||||
struct GltfMesh;
|
||||
|
||||
// Owns the Vulkan device (created through OpenXR) and the per-eye render
|
||||
// resources. For Phase 0/1 it draws a pose-anchored test triangle into each eye
|
||||
@@ -94,8 +96,17 @@ public:
|
||||
// Repositions the flat screen in front of the given head pose (headForward is
|
||||
// a world-space direction); resets the viewing distance to the default.
|
||||
void recenterScreen(const float headPos[3], const float headForward[3]);
|
||||
const float* screenPivot() const { return screenPivot_; }
|
||||
const float* screenForward() const { return screenForward_; }
|
||||
// Moves the flat screen farther (positive) or nearer (negative), clamped.
|
||||
void adjustScreenDistance(float delta);
|
||||
// Parametric curvature for cinema screen: 0.0 = flat plane, 1.0 = full cylinder wrap.
|
||||
void setScreenCurvature(float c) { screenCurvature_ = c < 0.0f ? 0.0f : (c > 1.0f ? 1.0f : c); }
|
||||
float screenCurvature() const { return screenCurvature_; }
|
||||
void adjustScreenCurvature(float delta) {
|
||||
setScreenCurvature(screenCurvature_ + delta);
|
||||
}
|
||||
|
||||
|
||||
// Transport HUD: progress in [0,1]. Drawn head-locked when visible.
|
||||
void setTransport(bool visible, float progress, bool paused,
|
||||
@@ -110,22 +121,113 @@ public:
|
||||
bool pointerOnTimeToggle() const { return pointerOnTimeToggle_; }
|
||||
void toggleTimeDisplay() { timeShowRemaining_ = !timeShowRemaining_; }
|
||||
|
||||
// File browser overlay: a title, list rows, selected index and a status line.
|
||||
struct BrowserItem {
|
||||
std::string name;
|
||||
std::string path;
|
||||
std::string detail;
|
||||
std::string tag;
|
||||
std::string durationTag;
|
||||
std::string sizeTag;
|
||||
std::string resolutionTag;
|
||||
std::string codecTag;
|
||||
std::string previewPath;
|
||||
bool isDir = false;
|
||||
bool isVideo = false;
|
||||
bool isBack = false;
|
||||
};
|
||||
|
||||
struct DownloadItem {
|
||||
std::string label;
|
||||
int state = 0; // 0=active, 1=done, 2=failed, 3=stopped
|
||||
float progress = 0.0f; // 0.0 to 1.0
|
||||
std::string detail;
|
||||
};
|
||||
|
||||
// File browser overlay: rich spatial card grid + sidebar navigation
|
||||
void setBrowserItems(bool visible, const std::string& title,
|
||||
const std::vector<BrowserItem>& items, int selected,
|
||||
const std::string& status);
|
||||
void setBrowser(bool visible, const std::string& title,
|
||||
const std::vector<std::string>& rows, int selected,
|
||||
const std::string& status);
|
||||
void updateCardThumbnail(int slot, const std::string& path, const uint8_t* rgba, int width, int height);
|
||||
void clearCardThumbnails();
|
||||
int browserFirst() const { return browserFirst_; }
|
||||
int browserSidebarHovered() const { return browserSidebarHovered_; }
|
||||
int browserPageBtnHovered() const { return browserPageBtnHovered_; }
|
||||
void setDownloadsFilter(int f) { downloadsFilter_ = f; }
|
||||
int downloadsFilter() const { return downloadsFilter_; }
|
||||
void setActiveDownloadCount(int c) { activeDownloadCount_ = c; }
|
||||
void setSettingsSection(int s) { settingsSection_ = s; }
|
||||
void setVaultUnlocked(bool u) { vaultUnlocked_ = u; }
|
||||
bool vaultUnlocked() const { return vaultUnlocked_; }
|
||||
void setUiAppearance(float textScale, int textColorIdx, int accentColorIdx,
|
||||
float uiDistance) {
|
||||
uiTextScale_ = textScale;
|
||||
uiTextColorIdx_ = textColorIdx;
|
||||
uiAccentColorIdx_ = accentColorIdx;
|
||||
uiDistance_ = uiDistance;
|
||||
}
|
||||
|
||||
// Downloads overlay: a simple list of download jobs plus a status line,
|
||||
// shown while downloads run (and briefly after) with the browser closed.
|
||||
enum class BrowserViewMode : int {
|
||||
BigCards = 0, // 3x2, 6 per page (Large cards with full 16:9 thumbnail)
|
||||
MiniCards = 1, // 4x3, 12 per page (Compact cards with 16:9 thumbnail)
|
||||
CompactList = 2 // 1x8, 8 per page (Single-column detailed configurable list view)
|
||||
};
|
||||
void setBrowserViewMode(BrowserViewMode mode) { browserViewMode_ = mode; }
|
||||
BrowserViewMode browserViewMode() const { return browserViewMode_; }
|
||||
void setBrowserQuickPath(bool isCurrentQuickPath) { browserIsQuickPath_ = isCurrentQuickPath; }
|
||||
void setBrowserBookmarked(bool isCurrentBookmarked) { browserIsQuickPath_ = isCurrentBookmarked; }
|
||||
bool browserIsQuickPath() const { return browserIsQuickPath_; }
|
||||
bool browserIsBookmarked() const { return browserIsQuickPath_; }
|
||||
void setListColumnVisibility(bool showFormat, bool showRes, bool showSize, bool showDuration) {
|
||||
listShowFormat_ = showFormat;
|
||||
listShowRes_ = showRes;
|
||||
listShowSize_ = showSize;
|
||||
listShowDuration_ = showDuration;
|
||||
}
|
||||
|
||||
struct KeyboardModalData {
|
||||
bool visible = false;
|
||||
std::string prompt;
|
||||
std::string text;
|
||||
bool isPassword = false;
|
||||
bool hasTransform = false;
|
||||
float transform[3][4]{};
|
||||
};
|
||||
void setKeyboardModal(bool visible, const std::string& prompt,
|
||||
const std::string& text, bool isPassword = false,
|
||||
const float* transform = nullptr) {
|
||||
keyboardModal_.visible = visible;
|
||||
keyboardModal_.prompt = prompt;
|
||||
keyboardModal_.text = text;
|
||||
keyboardModal_.isPassword = isPassword;
|
||||
if (transform && visible) {
|
||||
std::memcpy(keyboardModal_.transform, transform, sizeof(keyboardModal_.transform));
|
||||
keyboardModal_.hasTransform = true;
|
||||
} else {
|
||||
keyboardModal_.hasTransform = false;
|
||||
}
|
||||
}
|
||||
const KeyboardModalData& keyboardModal() const { return keyboardModal_; }
|
||||
|
||||
void setUiFollowMode(int mode) { uiFollowMode_ = mode; }
|
||||
int uiFollowMode() const { return uiFollowMode_; }
|
||||
|
||||
bool pointerActive() const { return pointerActive_; }
|
||||
|
||||
// Downloads overlay: modern glassmorphic window with job cards
|
||||
void setDownloads(bool visible, const std::string& title,
|
||||
const std::vector<std::string>& rows,
|
||||
const std::string& status,
|
||||
const std::vector<std::string>& buttons = {}) {
|
||||
const std::vector<std::string>& buttons = {},
|
||||
const std::vector<DownloadItem>& jobs = {}) {
|
||||
downloadsVisible_ = visible;
|
||||
downloadsTitle_ = title;
|
||||
downloadsRows_ = rows;
|
||||
downloadsStatus_ = status;
|
||||
downloadsButtons_ = buttons; // content-sized buttons along the bottom
|
||||
downloadJobs_ = jobs;
|
||||
}
|
||||
// Draws a stronger modal backdrop behind the panel (for confirmations).
|
||||
void setDownloadsModal(bool modal) { downloadsModal_ = modal; }
|
||||
@@ -133,6 +235,22 @@ public:
|
||||
int downloadsHovered() const { return downloadsHovered_; }
|
||||
int downloadsButtonHovered() const { return downloadsBtnHovered_; }
|
||||
|
||||
// File Action Submenu (opened by button Y in file browser)
|
||||
struct ActionMenuData {
|
||||
bool visible = false;
|
||||
int page = 0; // 0: Main, 1: MoveDestinations, 2: DeleteConfirm, 3: Rename
|
||||
std::string title;
|
||||
std::string subtitle;
|
||||
std::vector<std::string> options;
|
||||
int selectedOption = -1;
|
||||
std::string renameBuffer;
|
||||
};
|
||||
void setActionMenu(const ActionMenuData& data) { actionMenu_ = data; }
|
||||
const ActionMenuData& actionMenu() const { return actionMenu_; }
|
||||
int actionMenuBtnHovered() const { return actionMenuBtnHovered_; }
|
||||
int actionMenuKeyHovered() const { return actionMenuKeyHovered_; }
|
||||
void setBrowserNetwork(bool onNet) { browserOnNetwork_ = onNet; }
|
||||
|
||||
// Tracked controllers: aim poses used to draw pointer rays, controller
|
||||
// models and the UI cursor. Index 0 = left, 1 = right. Call once per frame,
|
||||
// after setBrowser, so the pointer can be raycast against the current panel.
|
||||
@@ -149,8 +267,11 @@ public:
|
||||
void setControllers(const ControllerVis& left, const ControllerVis& right);
|
||||
|
||||
// Replaces the procedural controller geometry with the runtime-provided glTF
|
||||
// render model (parsed from the GLB bytes). Call once when the asset is ready.
|
||||
bool loadControllerModel(const std::vector<uint8_t>& glb);
|
||||
// render model (parsed from the GLB bytes) for hand (0 = left, 1 = right).
|
||||
bool loadControllerModel(int hand, const std::vector<uint8_t>& glb);
|
||||
bool loadControllerModel(const std::vector<uint8_t>& glb) {
|
||||
return loadControllerModel(1, glb);
|
||||
}
|
||||
|
||||
// Per-node animated transforms from xrGetRenderModelStateEXT, matched to
|
||||
// submeshes by node name (drives button-press motion on the model).
|
||||
@@ -160,7 +281,10 @@ public:
|
||||
float quat[4] = {0.0f, 0.0f, 0.0f, 1.0f};
|
||||
bool visible = true;
|
||||
};
|
||||
void setControllerNodeStates(const std::vector<NodeStateVis>& states);
|
||||
void setControllerNodeStates(int hand, const std::vector<NodeStateVis>& states);
|
||||
void setControllerNodeStates(const std::vector<NodeStateVis>& states) {
|
||||
setControllerNodeStates(1, states);
|
||||
}
|
||||
|
||||
enum class UiShape : int {
|
||||
RoundedBox = 0,
|
||||
@@ -176,19 +300,23 @@ public:
|
||||
Checkmark = 10,
|
||||
Stop = 11,
|
||||
Download = 12,
|
||||
List = 13,
|
||||
GlowRing = 14,
|
||||
Speaker = 15,
|
||||
Headset = 16,
|
||||
CurvedDisplay = 17,
|
||||
Chevron = 18,
|
||||
Home = 19,
|
||||
};
|
||||
|
||||
// The browser row currently under the pointer, or -1 if none.
|
||||
int pointerRow() const { return pointerRow_; }
|
||||
int browserHovered() const { return browserHovered_; }
|
||||
int browserFirst() const { return browserFirst_; }
|
||||
void scrollBrowser(int deltaRows);
|
||||
|
||||
// Updates just the highlighted browser row (used by the pointer each frame).
|
||||
void setBrowserSelected(int index) { browserSelected_ = index; }
|
||||
|
||||
// Media control bar shown below the video (point-and-click transport). The
|
||||
// buttons are: seek back, play/pause, seek forward, open files, settings, recenter.
|
||||
enum class CtrlButton {
|
||||
SeekBack = 0,
|
||||
PlayPause = 1,
|
||||
@@ -196,9 +324,38 @@ public:
|
||||
Stop = 3,
|
||||
Files = 4,
|
||||
Settings = 5,
|
||||
Downloads = 6
|
||||
Downloads = 6, // open the downloads window (list of jobs)
|
||||
Download = 7, // download the focused file-browser entry
|
||||
FormatToggle = 8,
|
||||
AudioToggle = 9,
|
||||
ScreenToggle = 10,
|
||||
Recenter = 11, // recenter screen/view from HUD
|
||||
HideHud = 12 // immediately hide player controls
|
||||
};
|
||||
void setControlBar(bool visible) { controlBarVisible_ = visible; }
|
||||
bool controlBarVisible() const { return controlBarVisible_; }
|
||||
void setHudLabels(const std::string& format, const std::string& audio,
|
||||
const std::string& screen, bool formatEnabled = true,
|
||||
bool audioEnabled = true) {
|
||||
hudFormat_ = format;
|
||||
hudAudio_ = audio;
|
||||
hudScreen_ = screen;
|
||||
hudFormatEnabled_ = formatEnabled;
|
||||
hudAudioEnabled_ = audioEnabled;
|
||||
}
|
||||
|
||||
// Per-hand laser pointer toggle (grip full press). Only one controller has
|
||||
// an active laser pointer at any time. When a hand enables its laser, the
|
||||
// other hand's laser is turned off.
|
||||
void setLaserOn(int hand, bool on) {
|
||||
if (hand >= 0 && hand < 2) {
|
||||
laserOn_[hand] = on;
|
||||
if (on) laserOn_[1 - hand] = false;
|
||||
}
|
||||
}
|
||||
bool laserOn(int hand) const {
|
||||
return (hand >= 0 && hand < 2) ? laserOn_[hand] : false;
|
||||
}
|
||||
|
||||
// The control-bar button currently under the pointer, or -1 if none.
|
||||
int pointerButton() const { return pointerButton_; }
|
||||
@@ -305,9 +462,16 @@ private:
|
||||
bool downloadsFailed_ = false;
|
||||
bool downloadsModal_ = false;
|
||||
std::vector<std::string> downloadsButtons_;
|
||||
std::vector<DownloadItem> downloadJobs_;
|
||||
int downloadsBtnHovered_ = -1;
|
||||
int downloadsHovered_ = -1;
|
||||
|
||||
// Action menu state
|
||||
ActionMenuData actionMenu_{};
|
||||
int actionMenuBtnHovered_ = -1;
|
||||
int actionMenuKeyHovered_ = -1;
|
||||
bool browserOnNetwork_ = false;
|
||||
|
||||
// Flat-screen placement in the app reference space (updated by recenter and
|
||||
// thumbstick distance control).
|
||||
float screenPivot_[3] = {0.0f, 0.0f, 0.0f};
|
||||
@@ -318,6 +482,7 @@ private:
|
||||
float spherePitch_ = 0.0f; // 360/180 viewpoint tilt (radians)
|
||||
float sphereZoom_ = 1.0f; // 360/180 FOV scale (>1 pushes content farther)
|
||||
float screenDistance_ = 3.0f;
|
||||
float screenCurvature_ = 0.0f; // 0.0 = flat plane (default), 1.0 = deep cinema curve
|
||||
|
||||
// Transport HUD state.
|
||||
bool transportVisible_ = false;
|
||||
@@ -327,12 +492,40 @@ private:
|
||||
double transportDuration_ = 0.0; // seconds total
|
||||
bool timeShowRemaining_ = false; // right readout shows time-left vs. total
|
||||
bool pointerOnTimeToggle_ = false;
|
||||
std::string hudFormat_ = "2D FLAT";
|
||||
std::string hudAudio_ = "AUDIO";
|
||||
std::string hudScreen_ = "CURVED";
|
||||
bool hudFormatEnabled_ = true;
|
||||
bool hudAudioEnabled_ = true;
|
||||
float sphereFollowYaw_ = 0.0f;
|
||||
bool sphereFollowInit_ = false;
|
||||
|
||||
struct CardThumbnail {
|
||||
VkImage image = VK_NULL_HANDLE;
|
||||
VkDeviceMemory memory = VK_NULL_HANDLE;
|
||||
VkImageView view = VK_NULL_HANDLE;
|
||||
VkDescriptorSet set = VK_NULL_HANDLE;
|
||||
std::string path;
|
||||
bool ready = false;
|
||||
};
|
||||
CardThumbnail cardThumbs_[12];
|
||||
VkPipeline thumbPipeline_ = VK_NULL_HANDLE;
|
||||
VkDescriptorPool thumbPool_ = VK_NULL_HANDLE;
|
||||
BrowserViewMode browserViewMode_ = BrowserViewMode::BigCards;
|
||||
bool browserIsQuickPath_ = false;
|
||||
KeyboardModalData keyboardModal_{};
|
||||
int uiFollowMode_ = 0; // 0 = World Lock (Default, zero drift/tilt), 1 = Smooth Gaze Follow
|
||||
bool listShowFormat_ = true;
|
||||
bool listShowRes_ = true;
|
||||
bool listShowSize_ = true;
|
||||
bool listShowDuration_ = true;
|
||||
|
||||
// Text rendering (FreeType glyph atlas) + file browser overlay.
|
||||
FontAtlas fontAtlas_;
|
||||
VkImage fontImage_ = VK_NULL_HANDLE;
|
||||
VkDeviceMemory fontMemory_ = VK_NULL_HANDLE;
|
||||
VkImageView fontView_ = VK_NULL_HANDLE;
|
||||
VkSampler fontSampler_ = VK_NULL_HANDLE;
|
||||
VkDescriptorSetLayout textSetLayout_ = VK_NULL_HANDLE;
|
||||
VkDescriptorPool textPool_ = VK_NULL_HANDLE;
|
||||
VkDescriptorSet textSet_ = VK_NULL_HANDLE;
|
||||
@@ -347,6 +540,16 @@ private:
|
||||
int browserSelected_ = 0;
|
||||
int browserHovered_ = -1; // pointer hover target (independent of scroll)
|
||||
int browserFirst_ = 0; // scroll offset for visible window
|
||||
std::vector<BrowserItem> browserItems_;
|
||||
int browserSidebarHovered_ = -1;
|
||||
int browserPageBtnHovered_ = -1;
|
||||
int downloadsFilter_ = 0; // 0=All, 1=Active, 2=Completed
|
||||
int activeDownloadCount_ = 0;
|
||||
int settingsSection_ = 0;
|
||||
float uiTextScale_ = 1.20f;
|
||||
int uiTextColorIdx_ = 0;
|
||||
int uiAccentColorIdx_ = 0;
|
||||
float uiDistance_ = 1.9f;
|
||||
|
||||
// Settings panel overlay
|
||||
bool settingsVisible_ = false;
|
||||
@@ -363,8 +566,12 @@ private:
|
||||
bool pointerActive_ = false;
|
||||
int pointerRow_ = -1;
|
||||
int pointerHand_ = -1;
|
||||
// Whether each hand's laser pointer is on. Only one controller has an active
|
||||
// laser pointer at any time. Defaults to right controller active, left off.
|
||||
bool laserOn_[2] = {false, true};
|
||||
float pointerHit_[3] = {0.0f, 0.0f, 0.0f};
|
||||
bool controlBarVisible_ = false;
|
||||
bool vaultUnlocked_ = false;
|
||||
int pointerButton_ = -1;
|
||||
bool pointerOnTimeline_ = false;
|
||||
float pointerScrub_ = 0.0f;
|
||||
@@ -468,18 +675,18 @@ private:
|
||||
int resolveH_ = 0;
|
||||
uint32_t resolveMips_ = 1;
|
||||
|
||||
// Lit, textured 3D controller render models. The merged glTF geometry is
|
||||
// split into submeshes by material; each samples its own base-color texture
|
||||
// (which carries the part colors and printed button labels). Drawn with the
|
||||
// depth buffer so near parts occlude far ones.
|
||||
// Lit, textured 3D controller render models. The glTF geometry is loaded
|
||||
// separately for left (0) and right (1) hands into handModels_[2].
|
||||
// Drawn with the depth buffer so near parts occlude far ones.
|
||||
VkPipelineLayout modelLayout_ = VK_NULL_HANDLE;
|
||||
VkPipeline modelPipeline_ = VK_NULL_HANDLE;
|
||||
VkBuffer modelVertexBuffer_ = VK_NULL_HANDLE;
|
||||
VkDeviceMemory modelVertexMemory_ = VK_NULL_HANDLE;
|
||||
VkBuffer modelIndexBuffer_ = VK_NULL_HANDLE;
|
||||
VkDeviceMemory modelIndexMemory_ = VK_NULL_HANDLE;
|
||||
uint32_t modelIndexCount_ = 0;
|
||||
VkIndexType modelIndexType_ = VK_INDEX_TYPE_UINT16; // UINT32 for glTF models
|
||||
|
||||
// Procedural capsule fallback buffers (used before glTF is fetched)
|
||||
VkBuffer capsuleVertexBuffer_ = VK_NULL_HANDLE;
|
||||
VkDeviceMemory capsuleVertexMemory_ = VK_NULL_HANDLE;
|
||||
VkBuffer capsuleIndexBuffer_ = VK_NULL_HANDLE;
|
||||
VkDeviceMemory capsuleIndexMemory_ = VK_NULL_HANDLE;
|
||||
uint32_t capsuleIndexCount_ = 0;
|
||||
|
||||
struct ModelTexture {
|
||||
VkImage image = VK_NULL_HANDLE;
|
||||
@@ -494,13 +701,27 @@ private:
|
||||
float color[4] = {1.0f, 1.0f, 1.0f, 1.0f};
|
||||
std::string nodeName; // matched to an animated render-model node state
|
||||
};
|
||||
struct HandModel {
|
||||
VkBuffer vertexBuffer = VK_NULL_HANDLE;
|
||||
VkDeviceMemory vertexMemory = VK_NULL_HANDLE;
|
||||
VkBuffer indexBuffer = VK_NULL_HANDLE;
|
||||
VkDeviceMemory indexMemory = VK_NULL_HANDLE;
|
||||
uint32_t indexCount = 0;
|
||||
VkIndexType indexType = VK_INDEX_TYPE_UINT32;
|
||||
std::vector<ModelTexture> textures;
|
||||
std::vector<ModelSubmesh> submeshes;
|
||||
std::vector<NodeStateVis> nodeStates;
|
||||
bool ready = false;
|
||||
};
|
||||
HandModel handModels_[2]; // 0 = left, 1 = right
|
||||
|
||||
VkDescriptorSetLayout modelSetLayout_ = VK_NULL_HANDLE;
|
||||
VkDescriptorPool modelDescPool_ = VK_NULL_HANDLE;
|
||||
VkSampler modelSampler_ = VK_NULL_HANDLE;
|
||||
ModelTexture modelWhite_; // 1x1 white fallback for untextured submeshes
|
||||
std::vector<ModelTexture> modelTextures_;
|
||||
std::vector<ModelSubmesh> modelSubmeshes_;
|
||||
std::vector<NodeStateVis> modelNodeStates_;
|
||||
|
||||
void destroyHandModel(HandModel& hm);
|
||||
bool uploadHandMesh(int hand, const GltfMesh& mesh);
|
||||
bool createModelTexture(const uint8_t* rgba, int width, int height,
|
||||
ModelTexture& out);
|
||||
};
|
||||
|
||||
@@ -43,13 +43,31 @@ extern "C" {
|
||||
#include <cerrno>
|
||||
#endif
|
||||
|
||||
#include "net/smb_credentials.hpp"
|
||||
|
||||
namespace pixelvr {
|
||||
|
||||
#ifdef PIXELVR_HAVE_SMB
|
||||
namespace {
|
||||
|
||||
void smb_auth(SMBCCTX*, const char*, const char*, char*, int, char* un, int unlen,
|
||||
void smb_auth(SMBCCTX*, const char* srv, const char* shr, char* wg, int wglen, char* un, int unlen,
|
||||
char* pw, int pwlen) {
|
||||
std::string u, p, w;
|
||||
if (srv != nullptr && SmbCredentialsManager::instance().findCredentials(srv, shr ? shr : "", u, p, w)) {
|
||||
if (un && unlen > 0) {
|
||||
std::strncpy(un, u.c_str(), unlen - 1);
|
||||
un[unlen - 1] = '\0';
|
||||
}
|
||||
if (pw && pwlen > 0) {
|
||||
std::strncpy(pw, p.c_str(), pwlen - 1);
|
||||
pw[pwlen - 1] = '\0';
|
||||
}
|
||||
if (wg && wglen > 0 && !w.empty()) {
|
||||
std::strncpy(wg, w.c_str(), wglen - 1);
|
||||
wg[wglen - 1] = '\0';
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (un != nullptr && unlen > 0) un[0] = '\0'; // guest / anonymous
|
||||
if (pw != nullptr && pwlen > 0) pw[0] = '\0';
|
||||
}
|
||||
@@ -464,6 +482,11 @@ struct MediaEngine::Impl {
|
||||
return false;
|
||||
}
|
||||
codec->pkt_timebase = s->time_base;
|
||||
codec->thread_count = 0; // Multi-threaded SW decode across all CPU cores
|
||||
codec->thread_type = FF_THREAD_FRAME | FF_THREAD_SLICE;
|
||||
if (std::strstr(videoDecoder->name, "v4l2m2m") != nullptr) {
|
||||
av_opt_set_int(codec->priv_data, "num_output_buffers", 4, 0);
|
||||
}
|
||||
if (decoderDrm) {
|
||||
av_opt_set_int(codec->priv_data, "num_capture_buffers", captureBuffers, 0);
|
||||
av_opt_set_int(codec->priv_data, "drm_prime", 1, 0);
|
||||
@@ -922,7 +945,12 @@ bool MediaEngine::open(const std::string& path) {
|
||||
return false;
|
||||
}
|
||||
impl_->codec->pkt_timebase = stream->time_base;
|
||||
impl_->codec->thread_count = 0; // Auto-detect and utilize all CPU cores
|
||||
impl_->codec->thread_type = FF_THREAD_FRAME | FF_THREAD_SLICE;
|
||||
impl_->videoDecoder = dec; // retained so the decode thread can reopen on seek
|
||||
if (std::strstr(dec->name, "v4l2m2m") != nullptr) {
|
||||
av_opt_set_int(impl_->codec->priv_data, "num_output_buffers", 4, 0);
|
||||
}
|
||||
impl_->decoderDrm = drmPrime && std::strstr(dec->name, "v4l2m2m") != nullptr;
|
||||
if (impl_->decoderDrm) {
|
||||
// Export decoder output as DMA-BUF (AV_PIX_FMT_DRM_PRIME) for zero-copy
|
||||
|
||||
@@ -0,0 +1,486 @@
|
||||
#include "smb_credentials.hpp"
|
||||
#include "util/logging.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <iomanip>
|
||||
#include <random>
|
||||
#include <sstream>
|
||||
|
||||
#if defined(__linux__) || defined(__APPLE__)
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
#endif
|
||||
|
||||
namespace pixelvr {
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
namespace {
|
||||
|
||||
// Clean portable ChaCha20 stream cipher implementation
|
||||
// RFC 7539 / 8439
|
||||
static uint32_t rotl32(uint32_t x, int n) {
|
||||
return (x << n) | (x >> (32 - n));
|
||||
}
|
||||
|
||||
#define CHACHA20_QUARTERROUND(a, b, c, d) \
|
||||
a += b; d ^= a; d = rotl32(d, 16); \
|
||||
c += d; b ^= c; b = rotl32(b, 12); \
|
||||
a += b; d ^= a; d = rotl32(d, 8); \
|
||||
c += d; b ^= c; b = rotl32(b, 7);
|
||||
|
||||
static void chacha20_block(uint32_t out[16], const uint32_t in[16]) {
|
||||
for (int i = 0; i < 16; ++i) out[i] = in[i];
|
||||
for (int i = 0; i < 10; ++i) {
|
||||
CHACHA20_QUARTERROUND(out[0], out[4], out[8], out[12]);
|
||||
CHACHA20_QUARTERROUND(out[1], out[5], out[9], out[13]);
|
||||
CHACHA20_QUARTERROUND(out[2], out[6], out[10], out[14]);
|
||||
CHACHA20_QUARTERROUND(out[3], out[7], out[11], out[15]);
|
||||
CHACHA20_QUARTERROUND(out[0], out[5], out[10], out[15]);
|
||||
CHACHA20_QUARTERROUND(out[1], out[6], out[11], out[12]);
|
||||
CHACHA20_QUARTERROUND(out[2], out[7], out[8], out[13]);
|
||||
CHACHA20_QUARTERROUND(out[3], out[4], out[9], out[14]);
|
||||
}
|
||||
for (int i = 0; i < 16; ++i) out[i] += in[i];
|
||||
}
|
||||
|
||||
static void chacha20_crypt(const uint8_t key[32], const uint8_t nonce[12], uint32_t counter,
|
||||
const uint8_t* in, uint8_t* out, std::size_t len) {
|
||||
uint32_t state[16];
|
||||
// Constants "expand 32-byte k"
|
||||
state[0] = 0x61707865;
|
||||
state[1] = 0x3320646e;
|
||||
state[2] = 0x79622d32;
|
||||
state[3] = 0x6b206574;
|
||||
for (int i = 0; i < 8; ++i) {
|
||||
state[4 + i] = (static_cast<uint32_t>(key[i * 4 + 0])) |
|
||||
(static_cast<uint32_t>(key[i * 4 + 1]) << 8) |
|
||||
(static_cast<uint32_t>(key[i * 4 + 2]) << 16) |
|
||||
(static_cast<uint32_t>(key[i * 4 + 3]) << 24);
|
||||
}
|
||||
state[12] = counter;
|
||||
for (int i = 0; i < 3; ++i) {
|
||||
state[13 + i] = (static_cast<uint32_t>(nonce[i * 4 + 0])) |
|
||||
(static_cast<uint32_t>(nonce[i * 4 + 1]) << 8) |
|
||||
(static_cast<uint32_t>(nonce[i * 4 + 2]) << 16) |
|
||||
(static_cast<uint32_t>(nonce[i * 4 + 3]) << 24);
|
||||
}
|
||||
|
||||
uint32_t block[16];
|
||||
uint8_t keyStream[64];
|
||||
std::size_t offset = 0;
|
||||
while (offset < len) {
|
||||
chacha20_block(block, state);
|
||||
for (int i = 0; i < 16; ++i) {
|
||||
keyStream[i * 4 + 0] = static_cast<uint8_t>(block[i] & 0xFF);
|
||||
keyStream[i * 4 + 1] = static_cast<uint8_t>((block[i] >> 8) & 0xFF);
|
||||
keyStream[i * 4 + 2] = static_cast<uint8_t>((block[i] >> 16) & 0xFF);
|
||||
keyStream[i * 4 + 3] = static_cast<uint8_t>((block[i] >> 24) & 0xFF);
|
||||
}
|
||||
state[12]++;
|
||||
const std::size_t chunk = std::min<std::size_t>(64, len - offset);
|
||||
for (std::size_t i = 0; i < chunk; ++i) {
|
||||
out[offset + i] = in[offset + i] ^ keyStream[i];
|
||||
}
|
||||
offset += chunk;
|
||||
}
|
||||
}
|
||||
|
||||
static std::string toHex(const uint8_t* data, std::size_t len) {
|
||||
std::ostringstream oss;
|
||||
oss << std::hex << std::setfill('0');
|
||||
for (std::size_t i = 0; i < len; ++i) {
|
||||
oss << std::setw(2) << static_cast<int>(data[i]);
|
||||
}
|
||||
return oss.str();
|
||||
}
|
||||
|
||||
static std::vector<uint8_t> fromHex(const std::string& hex) {
|
||||
std::vector<uint8_t> bytes;
|
||||
if (hex.length() % 2 != 0) return bytes;
|
||||
bytes.reserve(hex.length() / 2);
|
||||
for (std::size_t i = 0; i < hex.length(); i += 2) {
|
||||
const std::string byteStr = hex.substr(i, 2);
|
||||
char* end = nullptr;
|
||||
unsigned long b = std::strtoul(byteStr.c_str(), &end, 16);
|
||||
if (end == byteStr.c_str() + 2) {
|
||||
bytes.push_back(static_cast<uint8_t>(b));
|
||||
} else {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
static std::string trim(const std::string& s) {
|
||||
auto start = s.find_first_not_of(" \t\r\n");
|
||||
if (start == std::string::npos) return "";
|
||||
auto end = s.find_last_not_of(" \t\r\n");
|
||||
return s.substr(start, end - start + 1);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
SmbCredentialsManager& SmbCredentialsManager::instance() {
|
||||
static SmbCredentialsManager sInstance;
|
||||
return sInstance;
|
||||
}
|
||||
|
||||
SmbCredentialsManager::SmbCredentialsManager() {
|
||||
load();
|
||||
}
|
||||
|
||||
std::string SmbCredentialsManager::getStoragePath() const {
|
||||
#ifdef _WIN32
|
||||
const char* appData = std::getenv("APPDATA");
|
||||
if (appData) return std::string(appData) + "/PixelVR/smb_shares.json";
|
||||
#else
|
||||
const char* home = std::getenv("HOME");
|
||||
if (home) return std::string(home) + "/.config/pixelvr/smb_shares.json";
|
||||
#endif
|
||||
return "pixelvr_smb_shares.json";
|
||||
}
|
||||
|
||||
std::string SmbCredentialsManager::getDeviceKey() const {
|
||||
std::string seed = "PixelVR-Frame-SMB-2026-Salt!";
|
||||
#if defined(__linux__)
|
||||
std::ifstream mid("/etc/machine-id");
|
||||
if (mid.is_open()) {
|
||||
std::string id;
|
||||
std::getline(mid, id);
|
||||
id = trim(id);
|
||||
if (!id.empty()) {
|
||||
seed += ":" + id;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
const char* user = std::getenv("USER");
|
||||
if (user) seed += ":" + std::string(user);
|
||||
|
||||
// Derive 32-byte key using double FNV-1a / DJB2 mixing
|
||||
uint64_t h1 = 14695981039346656037ULL;
|
||||
uint64_t h2 = 5381;
|
||||
for (char c : seed) {
|
||||
h1 = (h1 ^ static_cast<uint8_t>(c)) * 1099511628211ULL;
|
||||
h2 = ((h2 << 5) + h2) + static_cast<uint8_t>(c);
|
||||
}
|
||||
uint8_t keyBytes[32];
|
||||
for (int i = 0; i < 8; ++i) {
|
||||
keyBytes[i + 0] = static_cast<uint8_t>((h1 >> (i * 8)) & 0xFF);
|
||||
keyBytes[i + 8] = static_cast<uint8_t>((h2 >> (i * 8)) & 0xFF);
|
||||
keyBytes[i + 16] = static_cast<uint8_t>(((h1 ^ h2) >> (i * 8)) & 0xFF);
|
||||
keyBytes[i + 24] = static_cast<uint8_t>(((h1 + h2) >> (i * 8)) & 0xFF);
|
||||
}
|
||||
return std::string(reinterpret_cast<const char*>(keyBytes), 32);
|
||||
}
|
||||
|
||||
std::string SmbCredentialsManager::encryptPassword(const std::string& plain,
|
||||
const std::string& key,
|
||||
std::string& outIvHex) const {
|
||||
if (plain.empty()) {
|
||||
outIvHex.clear();
|
||||
return "";
|
||||
}
|
||||
uint8_t nonce[12];
|
||||
std::random_device rd;
|
||||
for (int i = 0; i < 12; ++i) {
|
||||
nonce[i] = static_cast<uint8_t>(rd() & 0xFF);
|
||||
}
|
||||
outIvHex = toHex(nonce, 12);
|
||||
|
||||
std::vector<uint8_t> cipher(plain.length());
|
||||
chacha20_crypt(reinterpret_cast<const uint8_t*>(key.data()), nonce, 1,
|
||||
reinterpret_cast<const uint8_t*>(plain.data()), cipher.data(), plain.length());
|
||||
return toHex(cipher.data(), cipher.size());
|
||||
}
|
||||
|
||||
std::string SmbCredentialsManager::decryptPassword(const std::string& cipherHex,
|
||||
const std::string& key,
|
||||
const std::string& ivHex) const {
|
||||
if (cipherHex.empty() || ivHex.empty()) return "";
|
||||
std::vector<uint8_t> cipher = fromHex(cipherHex);
|
||||
std::vector<uint8_t> nonce = fromHex(ivHex);
|
||||
if (cipher.empty() || nonce.size() != 12) return "";
|
||||
|
||||
std::string plain(cipher.size(), '\0');
|
||||
chacha20_crypt(reinterpret_cast<const uint8_t*>(key.data()), nonce.data(), 1,
|
||||
cipher.data(), reinterpret_cast<uint8_t*>(plain.data()), cipher.size());
|
||||
return plain;
|
||||
}
|
||||
|
||||
void SmbCredentialsManager::load() {
|
||||
std::lock_guard<std::mutex> lk(mutex_);
|
||||
shares_.clear();
|
||||
|
||||
const std::string path = getStoragePath();
|
||||
std::ifstream f(path);
|
||||
if (!f.is_open()) {
|
||||
return;
|
||||
}
|
||||
|
||||
std::string content((std::istreambuf_iterator<char>(f)),
|
||||
std::istreambuf_iterator<char>());
|
||||
f.close();
|
||||
|
||||
const std::string key = getDeviceKey();
|
||||
|
||||
// Lightweight robust JSON parser for share objects
|
||||
std::size_t pos = 0;
|
||||
while ((pos = content.find('{', pos)) != std::string::npos) {
|
||||
std::size_t endObj = content.find('}', pos);
|
||||
if (endObj == std::string::npos) break;
|
||||
|
||||
std::string obj = content.substr(pos, endObj - pos + 1);
|
||||
pos = endObj + 1;
|
||||
|
||||
auto extractField = [&](const std::string& keyName) -> std::string {
|
||||
const std::string pattern = "\"" + keyName + "\"";
|
||||
std::size_t kpos = obj.find(pattern);
|
||||
if (kpos == std::string::npos) return "";
|
||||
std::size_t colon = obj.find(':', kpos + pattern.length());
|
||||
if (colon == std::string::npos) return "";
|
||||
std::size_t startVal = obj.find_first_not_of(" \t\r\n", colon + 1);
|
||||
if (startVal == std::string::npos) return "";
|
||||
if (obj[startVal] == '\"') {
|
||||
std::size_t endQuote = obj.find('\"', startVal + 1);
|
||||
if (endQuote != std::string::npos) {
|
||||
return obj.substr(startVal + 1, endQuote - startVal - 1);
|
||||
}
|
||||
} else {
|
||||
std::size_t endVal = obj.find_first_of(",}\r\n", startVal);
|
||||
return trim(obj.substr(startVal, endVal - startVal));
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
SmbShare s;
|
||||
s.id = extractField("id");
|
||||
s.name = extractField("name");
|
||||
s.host = extractField("host");
|
||||
s.share = extractField("share");
|
||||
s.workgroup = extractField("workgroup");
|
||||
if (s.workgroup.empty()) s.workgroup = "WORKGROUP";
|
||||
s.username = extractField("username");
|
||||
s.anonymous = (extractField("anonymous") == "true");
|
||||
|
||||
std::string passCipher = extractField("password_enc");
|
||||
std::string passIv = extractField("iv");
|
||||
if (!passCipher.empty() && !passIv.empty()) {
|
||||
s.password = decryptPassword(passCipher, key, passIv);
|
||||
}
|
||||
|
||||
if (!s.host.empty()) {
|
||||
if (s.name.empty()) {
|
||||
s.name = s.host + (!s.share.empty() ? ("/" + s.share) : "");
|
||||
}
|
||||
if (s.id.empty()) {
|
||||
s.id = s.host + (!s.share.empty() ? ("/" + s.share) : "");
|
||||
}
|
||||
shares_.push_back(s);
|
||||
}
|
||||
}
|
||||
|
||||
PIXELVR_LOG_INFO("SmbCredentialsManager: Loaded %zu configured shares from %s",
|
||||
shares_.size(), path.c_str());
|
||||
}
|
||||
|
||||
void SmbCredentialsManager::save() {
|
||||
std::lock_guard<std::mutex> lk(mutex_);
|
||||
const std::string path = getStoragePath();
|
||||
|
||||
std::error_code ec;
|
||||
fs::path p(path);
|
||||
fs::create_directories(p.parent_path(), ec);
|
||||
|
||||
#if defined(__linux__) || defined(__APPLE__)
|
||||
// Ensure configuration directory is protected (0700)
|
||||
chmod(p.parent_path().string().c_str(), 0700);
|
||||
#endif
|
||||
|
||||
const std::string key = getDeviceKey();
|
||||
|
||||
std::ostringstream json;
|
||||
json << "[\n";
|
||||
for (std::size_t i = 0; i < shares_.size(); ++i) {
|
||||
const auto& s = shares_[i];
|
||||
std::string ivHex;
|
||||
std::string encPass = encryptPassword(s.password, key, ivHex);
|
||||
|
||||
json << " {\n";
|
||||
json << " \"id\": \"" << s.id << "\",\n";
|
||||
json << " \"name\": \"" << s.name << "\",\n";
|
||||
json << " \"host\": \"" << s.host << "\",\n";
|
||||
json << " \"share\": \"" << s.share << "\",\n";
|
||||
json << " \"workgroup\": \"" << s.workgroup << "\",\n";
|
||||
json << " \"username\": \"" << s.username << "\",\n";
|
||||
json << " \"password_enc\": \"" << encPass << "\",\n";
|
||||
json << " \"iv\": \"" << ivHex << "\",\n";
|
||||
json << " \"anonymous\": " << (s.anonymous ? "true" : "false") << "\n";
|
||||
json << " }" << (i + 1 < shares_.size() ? ",\n" : "\n");
|
||||
}
|
||||
json << "]\n";
|
||||
|
||||
std::ofstream f(path, std::ios::trunc);
|
||||
if (f.is_open()) {
|
||||
f << json.str();
|
||||
f.close();
|
||||
|
||||
#if defined(__linux__) || defined(__APPLE__)
|
||||
// Enforce strict OS-level permissions: readable & writable ONLY by steamos (0600)
|
||||
chmod(path.c_str(), 0600);
|
||||
#endif
|
||||
PIXELVR_LOG_INFO("SmbCredentialsManager: Saved %zu shares to %s (mode 0600)",
|
||||
shares_.size(), path.c_str());
|
||||
} else {
|
||||
PIXELVR_LOG_WARN("SmbCredentialsManager: Failed to write %s", path.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<SmbShare> SmbCredentialsManager::shares() const {
|
||||
std::lock_guard<std::mutex> lk(mutex_);
|
||||
return shares_;
|
||||
}
|
||||
|
||||
void SmbCredentialsManager::addOrUpdateShare(const SmbShare& share) {
|
||||
if (share.host.empty()) return;
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mutex_);
|
||||
bool found = false;
|
||||
for (auto& s : shares_) {
|
||||
if (s.host == share.host && s.share == share.share) {
|
||||
s = share;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
shares_.push_back(share);
|
||||
}
|
||||
}
|
||||
save();
|
||||
}
|
||||
|
||||
bool SmbCredentialsManager::deleteShare(const std::string& host, const std::string& share) {
|
||||
bool removed = false;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mutex_);
|
||||
auto it = std::remove_if(shares_.begin(), shares_.end(), [&](const SmbShare& s) {
|
||||
if (s.host == host) {
|
||||
if (share.empty() || s.share == share || s.share.empty()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (it != shares_.end()) {
|
||||
shares_.erase(it, shares_.end());
|
||||
removed = true;
|
||||
}
|
||||
}
|
||||
if (removed) {
|
||||
save();
|
||||
PIXELVR_LOG_INFO("SmbCredentialsManager: Deleted share for host '%s'", host.c_str());
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
bool SmbCredentialsManager::findShare(const std::string& hostIn,
|
||||
const std::string& shareIn,
|
||||
SmbShare& outShare) const {
|
||||
std::lock_guard<std::mutex> lk(mutex_);
|
||||
if (hostIn.empty()) return false;
|
||||
|
||||
std::string host = hostIn;
|
||||
std::transform(host.begin(), host.end(), host.begin(), ::tolower);
|
||||
std::string share = shareIn;
|
||||
std::transform(share.begin(), share.end(), share.begin(), ::tolower);
|
||||
|
||||
for (const auto& s : shares_) {
|
||||
std::string sHost = s.host;
|
||||
std::transform(sHost.begin(), sHost.end(), sHost.begin(), ::tolower);
|
||||
if (sHost == host) {
|
||||
std::string sShare = s.share;
|
||||
std::transform(sShare.begin(), sShare.end(), sShare.begin(), ::tolower);
|
||||
if (sShare == share || (share.empty() && sShare.empty())) {
|
||||
outShare = s;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool SmbCredentialsManager::findCredentials(const std::string& hostIn,
|
||||
const std::string& shareIn,
|
||||
std::string& outUser,
|
||||
std::string& outPass,
|
||||
std::string& outWorkgroup) const {
|
||||
std::lock_guard<std::mutex> lk(mutex_);
|
||||
if (hostIn.empty()) return false;
|
||||
|
||||
std::string host = hostIn;
|
||||
std::transform(host.begin(), host.end(), host.begin(), ::tolower);
|
||||
std::string share = shareIn;
|
||||
std::transform(share.begin(), share.end(), share.begin(), ::tolower);
|
||||
|
||||
const SmbShare* bestMatch = nullptr;
|
||||
for (const auto& s : shares_) {
|
||||
std::string sHost = s.host;
|
||||
std::transform(sHost.begin(), sHost.end(), sHost.begin(), ::tolower);
|
||||
if (sHost == host) {
|
||||
std::string sShare = s.share;
|
||||
std::transform(sShare.begin(), sShare.end(), sShare.begin(), ::tolower);
|
||||
if (!share.empty() && sShare == share) {
|
||||
bestMatch = &s; // Exact share match takes highest priority
|
||||
break;
|
||||
}
|
||||
if (sShare.empty() && !bestMatch) {
|
||||
bestMatch = &s; // Host-level wildcard match
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bestMatch && !bestMatch->anonymous) {
|
||||
outUser = bestMatch->username;
|
||||
outPass = bestMatch->password;
|
||||
outWorkgroup = bestMatch->workgroup.empty() ? "WORKGROUP" : bestMatch->workgroup;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string SmbCredentialsManager::getAuthFilePath(const std::string& host, const std::string& share) {
|
||||
std::string user, pass, wg;
|
||||
if (!findCredentials(host, share, user, pass, wg)) {
|
||||
return "";
|
||||
}
|
||||
if (user.empty()) return "";
|
||||
|
||||
std::string basePath = getStoragePath();
|
||||
fs::path p(basePath);
|
||||
std::string safeHost = host;
|
||||
for (char& c : safeHost) {
|
||||
if (!isalnum(c)) c = '_';
|
||||
}
|
||||
fs::path authFile = p.parent_path() / ("auth_" + safeHost + ".tmp");
|
||||
|
||||
std::ofstream f(authFile.string(), std::ios::trunc);
|
||||
if (!f.is_open()) return "";
|
||||
|
||||
f << "username = " << user << "\n";
|
||||
f << "password = " << pass << "\n";
|
||||
f << "domain = " << (wg.empty() ? "WORKGROUP" : wg) << "\n";
|
||||
f.close();
|
||||
|
||||
#if defined(__linux__) || defined(__APPLE__)
|
||||
chmod(authFile.string().c_str(), 0600);
|
||||
#endif
|
||||
return authFile.string();
|
||||
}
|
||||
|
||||
} // namespace pixelvr
|
||||
@@ -0,0 +1,69 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <mutex>
|
||||
|
||||
namespace pixelvr {
|
||||
|
||||
struct SmbShare {
|
||||
std::string id; // Unique identifier (typically host or host:share)
|
||||
std::string name; // User-friendly display label (e.g. "Home NAS")
|
||||
std::string host; // IP address or hostname (e.g. "192.168.1.50")
|
||||
std::string share; // Optional default share name (e.g. "movies", or empty for all)
|
||||
std::string workgroup; // Workgroup/Domain (defaults to "WORKGROUP")
|
||||
std::string username; // SMB Username (or empty for anonymous)
|
||||
std::string password; // SMB Password (decrypted in memory, encrypted on disk)
|
||||
bool anonymous = false; // True for guest / public shares
|
||||
};
|
||||
|
||||
class SmbCredentialsManager {
|
||||
public:
|
||||
static SmbCredentialsManager& instance();
|
||||
|
||||
// Loads configured shares from ~/.config/pixelvr/smb_shares.json
|
||||
void load();
|
||||
|
||||
// Saves configured shares to ~/.config/pixelvr/smb_shares.json with strict 0600 permissions
|
||||
void save();
|
||||
|
||||
// Returns all configured SMB shares
|
||||
std::vector<SmbShare> shares() const;
|
||||
|
||||
// Adds a new share or updates an existing one (matched by host + share)
|
||||
void addOrUpdateShare(const SmbShare& share);
|
||||
|
||||
// Deletes a configured share and wipes its stored credentials
|
||||
bool deleteShare(const std::string& host, const std::string& share = "");
|
||||
|
||||
// Lookup a configured share
|
||||
bool findShare(const std::string& host,
|
||||
const std::string& share,
|
||||
SmbShare& outShare) const;
|
||||
|
||||
// Lookup credentials for a given host and optional share
|
||||
bool findCredentials(const std::string& host,
|
||||
const std::string& share,
|
||||
std::string& outUser,
|
||||
std::string& outPass,
|
||||
std::string& outWorkgroup) const;
|
||||
|
||||
// Generates or updates a secure temporary Samba auth file (mode 0600) for smbclient -A
|
||||
// Returns the absolute file path, or empty string if anonymous / no credentials.
|
||||
std::string getAuthFilePath(const std::string& host, const std::string& share);
|
||||
|
||||
private:
|
||||
SmbCredentialsManager();
|
||||
~SmbCredentialsManager() = default;
|
||||
|
||||
std::string getStoragePath() const;
|
||||
std::string getDeviceKey() const;
|
||||
|
||||
std::string encryptPassword(const std::string& plain, const std::string& key, std::string& outIv) const;
|
||||
std::string decryptPassword(const std::string& cipherHex, const std::string& key, const std::string& ivHex) const;
|
||||
|
||||
mutable std::mutex mutex_;
|
||||
std::vector<SmbShare> shares_;
|
||||
};
|
||||
|
||||
} // namespace pixelvr
|
||||
@@ -1,4 +1,6 @@
|
||||
#include "ui/browser.hpp"
|
||||
#include "app/vault_manager.hpp"
|
||||
#include "net/smb_credentials.hpp"
|
||||
|
||||
#include "util/logging.hpp"
|
||||
#include "util/smb_lock.hpp"
|
||||
@@ -7,7 +9,9 @@
|
||||
#include <cctype>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <functional>
|
||||
#include <mutex>
|
||||
#include <set>
|
||||
@@ -47,8 +51,24 @@ bool is_video(const std::string& name) {
|
||||
}
|
||||
|
||||
#ifdef PIXELVR_HAVE_SMB
|
||||
void smb_auth(const char*, const char*, char*, int, char* user, int userLen,
|
||||
void smb_auth(const char* srv, const char* shr, char* wg, int wgLen, char* user, int userLen,
|
||||
char* pass, int passLen) {
|
||||
std::string u, p, w;
|
||||
if (srv != nullptr && SmbCredentialsManager::instance().findCredentials(srv, shr ? shr : "", u, p, w)) {
|
||||
if (user && userLen > 0) {
|
||||
std::strncpy(user, u.c_str(), userLen - 1);
|
||||
user[userLen - 1] = '\0';
|
||||
}
|
||||
if (pass && passLen > 0) {
|
||||
std::strncpy(pass, p.c_str(), passLen - 1);
|
||||
pass[passLen - 1] = '\0';
|
||||
}
|
||||
if (wg && wgLen > 0 && !w.empty()) {
|
||||
std::strncpy(wg, w.c_str(), wgLen - 1);
|
||||
wg[wgLen - 1] = '\0';
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (user != nullptr && userLen > 0) user[0] = '\0'; // anonymous / guest
|
||||
if (pass != nullptr && passLen > 0) pass[0] = '\0';
|
||||
}
|
||||
@@ -81,11 +101,14 @@ std::string smb_parent(const std::string& uri) {
|
||||
void sort_and_append(std::vector<Browser::Entry>& out,
|
||||
std::vector<Browser::Entry>& dirs,
|
||||
std::vector<Browser::Entry>& files) {
|
||||
auto by_label = [](const Browser::Entry& a, const Browser::Entry& b) {
|
||||
auto by_media_then_label = [](const Browser::Entry& a, const Browser::Entry& b) {
|
||||
const bool aHasMedia = a.isVideo || !a.previewPath.empty();
|
||||
const bool bHasMedia = b.isVideo || !b.previewPath.empty();
|
||||
if (aHasMedia != bHasMedia) return aHasMedia > bHasMedia;
|
||||
return a.label < b.label;
|
||||
};
|
||||
std::sort(dirs.begin(), dirs.end(), by_label);
|
||||
std::sort(files.begin(), files.end(), by_label);
|
||||
std::sort(dirs.begin(), dirs.end(), by_media_then_label);
|
||||
std::sort(files.begin(), files.end(), by_media_then_label);
|
||||
out.insert(out.end(), dirs.begin(), dirs.end());
|
||||
out.insert(out.end(), files.begin(), files.end());
|
||||
}
|
||||
@@ -96,6 +119,8 @@ Browser::Browser() {
|
||||
const char* home = std::getenv("HOME");
|
||||
homeDir_ = (home != nullptr) ? home : "/home/steamos";
|
||||
downloadDir_ = homeDir_ + "/media";
|
||||
quickPath_ = homeDir_ + "/Videos";
|
||||
loadBookmarks();
|
||||
}
|
||||
|
||||
void Browser::open() {
|
||||
@@ -141,7 +166,11 @@ std::string format_size(long long bytes) {
|
||||
void Browser::scanLocalInto(const std::string& dir, ScanResult& out) {
|
||||
out.inSmb = false;
|
||||
out.location = dir;
|
||||
out.title = dir;
|
||||
if (VaultManager::instance().isVaultPath(dir)) {
|
||||
out.title = "Vault (Private)";
|
||||
} else {
|
||||
out.title = dir;
|
||||
}
|
||||
out.entries.clear();
|
||||
|
||||
if (dir == homeDir_) {
|
||||
@@ -160,12 +189,65 @@ void Browser::scanLocalInto(const std::string& dir, ScanResult& out) {
|
||||
if (name.empty() || name[0] == '.') continue; // skip hidden
|
||||
std::error_code dec;
|
||||
if (it->is_directory(dec)) {
|
||||
dirs.push_back({name + "/", p.string(), true, false, false, false});
|
||||
if (dir == homeDir_) {
|
||||
if (name == "devkit-game" || name == "devkit-utils" ||
|
||||
name == "ffbuild" || name == "pixelvr-src" ||
|
||||
name == "steamvr" || name == "work" ||
|
||||
name == "Desktop" || name == "Templates" ||
|
||||
name == "Public" || name == "Documents" || name == "Music") {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
std::string preview;
|
||||
long long bestPreviewSize = -1;
|
||||
int videoCount = 0;
|
||||
std::error_code sec;
|
||||
for (fs::directory_iterator subIt(p, sec), subEnd; !sec && subIt != subEnd; subIt.increment(sec)) {
|
||||
const std::string subName = subIt->path().filename().string();
|
||||
if (subName.empty() || subName[0] == '.') continue;
|
||||
std::error_code sdec;
|
||||
if (!subIt->is_directory(sdec) && is_video(subName)) {
|
||||
std::error_code fsec;
|
||||
const long long vsz = static_cast<long long>(fs::file_size(subIt->path(), fsec));
|
||||
const long long score = (!fsec ? vsz : 1);
|
||||
if (score > bestPreviewSize) {
|
||||
bestPreviewSize = score;
|
||||
preview = subIt->path().string();
|
||||
}
|
||||
++videoCount;
|
||||
} else if (subIt->is_directory(sdec)) {
|
||||
// Check one level deeper (e.g. ~/Videos/MovieFolder/movie.mp4)
|
||||
std::error_code ssec2;
|
||||
for (fs::directory_iterator subIt2(subIt->path(), ssec2), subEnd2;
|
||||
!ssec2 && subIt2 != subEnd2; subIt2.increment(ssec2)) {
|
||||
const std::string subName2 = subIt2->path().filename().string();
|
||||
if (subName2.empty() || subName2[0] == '.') continue;
|
||||
if (!subIt2->is_directory(ssec2) && is_video(subName2)) {
|
||||
std::error_code fsec2;
|
||||
const long long vsz2 = static_cast<long long>(fs::file_size(subIt2->path(), fsec2));
|
||||
const long long score2 = (!fsec2 ? vsz2 : 1);
|
||||
if (score2 > bestPreviewSize) {
|
||||
bestPreviewSize = score2;
|
||||
preview = subIt2->path().string();
|
||||
}
|
||||
++videoCount;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
std::string detail = (videoCount > 0)
|
||||
? (std::to_string(videoCount) + (videoCount == 1 ? " video" : " videos") +
|
||||
(bestPreviewSize > 0 ? " \xB7 " + format_size(bestPreviewSize) : ""))
|
||||
: "Folder";
|
||||
dirs.push_back({name + "/", p.string(), true, false, false, false, detail,
|
||||
bestPreviewSize > 0 ? bestPreviewSize : 0, preview});
|
||||
} else {
|
||||
const bool isVid = is_video(name);
|
||||
if (dir == homeDir_ && !isVid) continue; // hide log/tarball files in home root
|
||||
std::error_code sec;
|
||||
const long long sz = static_cast<long long>(fs::file_size(p, sec));
|
||||
files.push_back({name, p.string(), false, is_video(name), false, false,
|
||||
format_size(sec ? 0 : sz), sec ? 0 : sz});
|
||||
files.push_back({name, p.string(), false, isVid, false, false,
|
||||
format_size(sec ? 0 : sz), sec ? 0 : sz, isVid ? p.string() : ""});
|
||||
}
|
||||
}
|
||||
sort_and_append(out.entries, dirs, files);
|
||||
@@ -227,10 +309,31 @@ void Browser::scanSmbInto(const std::string& uri, ScanResult& out) {
|
||||
}
|
||||
|
||||
// NetBIOS browsing often misses modern servers; augment the network root with
|
||||
// mDNS-advertised SMB hosts (NAS, macOS, Samba+avahi). The slow network probe
|
||||
// runs OUTSIDE the SMB lock (it is not a libsmbclient call).
|
||||
// configured manual shares and mDNS-advertised SMB hosts (NAS, macOS, Samba+avahi).
|
||||
if (uri == "smb://") {
|
||||
std::set<std::string> seen;
|
||||
// The very first entry in the SMB list is the "+ Add SMB Share" button!
|
||||
dirs.push_back({"+ Add SMB Share", "smb://__add_share__", true, false, false, false, "Configure New Network Share"});
|
||||
seen.insert("smb://__add_share__");
|
||||
|
||||
// Prepend all user-configured manual SMB shares so private/unadvertised shares always appear
|
||||
const auto configuredShares = SmbCredentialsManager::instance().shares();
|
||||
for (const auto& s : configuredShares) {
|
||||
if (s.host.empty()) continue;
|
||||
std::string child = "smb://" + s.host;
|
||||
if (!s.share.empty()) {
|
||||
if (child.back() != '/') child += '/';
|
||||
child += s.share;
|
||||
}
|
||||
if (!seen.insert(child).second) continue;
|
||||
std::string label = s.name.empty() ? s.host : s.name;
|
||||
std::string detail = s.anonymous ? "Configured (Guest)" : "Configured (Protected)";
|
||||
if (!s.share.empty()) {
|
||||
dirs.push_back({label + " (" + s.host + "/" + s.share + ")", child, true, false, false, false, detail});
|
||||
} else {
|
||||
dirs.push_back({label + " (" + s.host + ")", child, true, false, false, false, detail});
|
||||
}
|
||||
}
|
||||
for (const auto& d : dirs) seen.insert(d.path);
|
||||
FILE* pipe = popen("timeout 4 avahi-browse -trp _smb._tcp 2>/dev/null", "r");
|
||||
if (pipe != nullptr) {
|
||||
@@ -305,9 +408,339 @@ void Browser::openPath(const std::string& localDir) {
|
||||
beginScan(localDir, false);
|
||||
}
|
||||
|
||||
void Browser::openNetwork() {
|
||||
open_ = true;
|
||||
browseOpen_.store(true);
|
||||
status_.clear();
|
||||
beginScan("smb://", true);
|
||||
}
|
||||
|
||||
void Browser::openHome() {
|
||||
openPath(homeDir_);
|
||||
}
|
||||
|
||||
void Browser::openVideos() {
|
||||
const std::string v = homeDir_ + "/Videos";
|
||||
std::error_code ec;
|
||||
if (fs::is_directory(v, ec)) {
|
||||
openPath(v);
|
||||
} else {
|
||||
openPath(homeDir_);
|
||||
}
|
||||
}
|
||||
|
||||
void Browser::openDownloads() {
|
||||
std::error_code ec;
|
||||
if (!fs::is_directory(downloadDir_, ec)) {
|
||||
fs::create_directories(downloadDir_, ec);
|
||||
}
|
||||
openPath(downloadDir_);
|
||||
}
|
||||
|
||||
void Browser::openDrives() {
|
||||
const std::string d = "/run/media";
|
||||
std::error_code ec;
|
||||
if (fs::is_directory(d, ec)) {
|
||||
openPath(d);
|
||||
} else {
|
||||
openPath("/");
|
||||
}
|
||||
}
|
||||
|
||||
void Browser::openVault() {
|
||||
if (!VaultManager::instance().isUnlocked()) return;
|
||||
VaultManager::instance().ensureDirectories();
|
||||
openPath(VaultManager::instance().vaultDir());
|
||||
}
|
||||
|
||||
void Browser::openBookmarks() {
|
||||
open_ = true;
|
||||
browseOpen_.store(true);
|
||||
status_.clear();
|
||||
beginScan("bookmarks://", false);
|
||||
}
|
||||
|
||||
void Browser::addBookmark(const std::string& path, const std::string& label) {
|
||||
if (path.empty() || path == "bookmarks://" || path == "smb://__add_share__") return;
|
||||
for (auto& b : bookmarks_) {
|
||||
if (b.path == path) return;
|
||||
}
|
||||
std::string l = label;
|
||||
if (l.empty()) {
|
||||
if (path.rfind("smb://", 0) == 0) {
|
||||
std::string rest = path.substr(6);
|
||||
while (!rest.empty() && rest.back() == '/') rest.pop_back();
|
||||
l = rest;
|
||||
} else {
|
||||
fs::path p(path);
|
||||
l = p.filename().string();
|
||||
if (l.empty()) l = path;
|
||||
}
|
||||
}
|
||||
bookmarks_.push_back({path, l});
|
||||
quickPath_ = path;
|
||||
saveBookmarks();
|
||||
if (location_ == "bookmarks://") {
|
||||
beginScan("bookmarks://", false);
|
||||
}
|
||||
}
|
||||
|
||||
void Browser::removeBookmark(const std::string& path) {
|
||||
auto it = std::remove_if(bookmarks_.begin(), bookmarks_.end(), [&](const Bookmark& b) {
|
||||
return b.path == path;
|
||||
});
|
||||
if (it != bookmarks_.end()) {
|
||||
bookmarks_.erase(it, bookmarks_.end());
|
||||
saveBookmarks();
|
||||
if (location_ == "bookmarks://") {
|
||||
beginScan("bookmarks://", false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool Browser::isBookmarked(const std::string& path) const {
|
||||
if (path.empty() || path == "bookmarks://") return false;
|
||||
for (const auto& b : bookmarks_) {
|
||||
if (b.path == path) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void Browser::loadBookmarks() {
|
||||
bookmarks_.clear();
|
||||
const char* home = std::getenv("HOME");
|
||||
std::string path = std::string(home ? home : ".") + "/.config/pixelvr/bookmarks.json";
|
||||
std::ifstream f(path);
|
||||
if (f.is_open()) {
|
||||
std::string content((std::istreambuf_iterator<char>(f)), std::istreambuf_iterator<char>());
|
||||
f.close();
|
||||
|
||||
std::size_t pos = 0;
|
||||
while ((pos = content.find('{', pos)) != std::string::npos) {
|
||||
std::size_t endObj = content.find('}', pos);
|
||||
if (endObj == std::string::npos) break;
|
||||
std::string obj = content.substr(pos, endObj - pos + 1);
|
||||
pos = endObj + 1;
|
||||
|
||||
auto extract = [&](const std::string& key) -> std::string {
|
||||
std::string pat = "\"" + key + "\"";
|
||||
auto kpos = obj.find(pat);
|
||||
if (kpos == std::string::npos) return "";
|
||||
auto col = obj.find(':', kpos + pat.length());
|
||||
if (col == std::string::npos) return "";
|
||||
auto q1 = obj.find('\"', col + 1);
|
||||
if (q1 == std::string::npos) return "";
|
||||
auto q2 = obj.find('\"', q1 + 1);
|
||||
if (q2 == std::string::npos) return "";
|
||||
return obj.substr(q1 + 1, q2 - q1 - 1);
|
||||
};
|
||||
std::string bPath = extract("path");
|
||||
std::string bLabel = extract("label");
|
||||
if (!bPath.empty()) {
|
||||
bookmarks_.push_back({bPath, bLabel});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bookmarks_.empty() && !quickPath_.empty()) {
|
||||
addBookmark(quickPath_);
|
||||
}
|
||||
}
|
||||
|
||||
void Browser::saveBookmarks() const {
|
||||
const char* home = std::getenv("HOME");
|
||||
std::string configDir = std::string(home ? home : ".") + "/.config/pixelvr";
|
||||
std::error_code ec;
|
||||
std::filesystem::create_directories(configDir, ec);
|
||||
|
||||
std::string path = configDir + "/bookmarks.json";
|
||||
std::ofstream f(path, std::ios::trunc);
|
||||
if (!f.is_open()) return;
|
||||
|
||||
f << "[\n";
|
||||
for (std::size_t i = 0; i < bookmarks_.size(); ++i) {
|
||||
f << " {\n";
|
||||
f << " \"path\": \"" << bookmarks_[i].path << "\",\n";
|
||||
f << " \"label\": \"" << bookmarks_[i].label << "\"\n";
|
||||
f << " }" << (i + 1 < bookmarks_.size() ? ",\n" : "\n");
|
||||
}
|
||||
f << "]\n";
|
||||
f.close();
|
||||
}
|
||||
|
||||
void Browser::scanBookmarksInto(ScanResult& out) {
|
||||
out.inSmb = false;
|
||||
out.location = "bookmarks://";
|
||||
out.title = "Bookmarks";
|
||||
out.entries.clear();
|
||||
out.entries.push_back({".. (Home)", homeDir_, true, false, true, false});
|
||||
|
||||
if (bookmarks_.empty()) {
|
||||
out.status = "No bookmarks yet. Click [+ Bookmark] in any folder to add one.";
|
||||
return;
|
||||
}
|
||||
|
||||
out.status = std::to_string(bookmarks_.size()) + (bookmarks_.size() == 1 ? " bookmark" : " bookmarks");
|
||||
|
||||
for (const auto& bm : bookmarks_) {
|
||||
if (bm.path.empty()) continue;
|
||||
const bool isSmb = (bm.path.rfind("smb://", 0) == 0);
|
||||
std::error_code ec;
|
||||
const bool isVid = is_video(bm.path);
|
||||
const bool isDir = isSmb ? !isVid : fs::is_directory(bm.path, ec);
|
||||
std::string label = bm.label;
|
||||
if (label.empty()) {
|
||||
if (isSmb) {
|
||||
label = bm.path.substr(6);
|
||||
while (!label.empty() && label.back() == '/') label.pop_back();
|
||||
} else {
|
||||
fs::path p(bm.path);
|
||||
label = p.filename().string();
|
||||
if (label.empty()) label = bm.path;
|
||||
}
|
||||
}
|
||||
std::string detail = isSmb ? (isVid ? "SMB Video" : "SMB Share") : (isDir ? "Folder" : "Video File");
|
||||
std::string previewPath = isDir ? "" : bm.path;
|
||||
out.entries.push_back({label, bm.path, isDir, isVid, false, false, detail, 0, previewPath});
|
||||
}
|
||||
}
|
||||
|
||||
bool Browser::inVault() const {
|
||||
return VaultManager::instance().isVaultPath(location_);
|
||||
}
|
||||
|
||||
bool Browser::moveFocusedToVault(std::string& outNewPath) {
|
||||
if (!VaultManager::instance().isUnlocked()) return false;
|
||||
const Entry* e = focused();
|
||||
if (e == nullptr || e->isBack || inSmb_) return false;
|
||||
std::string err = VaultManager::instance().moveFileToVault(e->path, outNewPath);
|
||||
if (!err.empty()) {
|
||||
status_ = "Move error: " + err;
|
||||
return false;
|
||||
}
|
||||
status_ = "Moved " + e->label + " to Vault";
|
||||
beginScan(location_, inSmb_);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Browser::moveFocusedTo(const std::string& destDir, std::string& outNewPath) {
|
||||
const Entry* e = focused();
|
||||
if (e == nullptr || e->isBack || inSmb_) return false;
|
||||
std::error_code ec;
|
||||
if (!fs::exists(destDir, ec) || !fs::is_directory(destDir, ec)) {
|
||||
status_ = "Destination folder does not exist";
|
||||
return false;
|
||||
}
|
||||
fs::path src(e->path);
|
||||
const bool isDir = fs::is_directory(src, ec);
|
||||
std::string stem = src.stem().string();
|
||||
std::string ext = isDir ? "" : src.extension().string();
|
||||
std::string targetFilename = src.filename().string();
|
||||
fs::path dest = fs::path(destDir) / targetFilename;
|
||||
|
||||
int counter = 1;
|
||||
while (fs::exists(dest, ec)) {
|
||||
targetFilename = isDir ? (stem + "_" + std::to_string(counter++))
|
||||
: (stem + "_" + std::to_string(counter++) + ext);
|
||||
dest = fs::path(destDir) / targetFilename;
|
||||
}
|
||||
|
||||
fs::rename(src, dest, ec);
|
||||
if (ec) {
|
||||
ec.clear();
|
||||
if (isDir) {
|
||||
fs::copy(src, dest, fs::copy_options::recursive, ec);
|
||||
if (ec) {
|
||||
status_ = "Move failed: " + ec.message();
|
||||
return false;
|
||||
}
|
||||
fs::remove_all(src, ec);
|
||||
} else {
|
||||
fs::copy_file(src, dest, fs::copy_options::overwrite_existing, ec);
|
||||
if (ec) {
|
||||
status_ = "Move failed: " + ec.message();
|
||||
return false;
|
||||
}
|
||||
fs::remove(src, ec);
|
||||
}
|
||||
}
|
||||
outNewPath = dest.string();
|
||||
status_ = "Moved " + e->label + " to " + fs::path(destDir).filename().string();
|
||||
beginScan(location_, inSmb_);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Browser::deleteFocused(std::string& deletedName) {
|
||||
const Entry* e = focused();
|
||||
if (e == nullptr || e->isBack || inSmb_) return false;
|
||||
std::error_code ec;
|
||||
deletedName = e->label;
|
||||
fs::path p(e->path);
|
||||
VaultManager::instance().cleanPublicThumbs(e->path);
|
||||
if (fs::is_directory(p, ec)) {
|
||||
fs::remove_all(p, ec);
|
||||
} else {
|
||||
fs::remove(p, ec);
|
||||
}
|
||||
if (ec) {
|
||||
status_ = "Delete failed: " + ec.message();
|
||||
return false;
|
||||
}
|
||||
status_ = "Deleted " + deletedName;
|
||||
beginScan(location_, inSmb_);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Browser::renameFocused(const std::string& newName) {
|
||||
const Entry* e = focused();
|
||||
if (e == nullptr || e->isBack || inSmb_ || newName.empty()) return false;
|
||||
std::error_code ec;
|
||||
fs::path src(e->path);
|
||||
fs::path dest = src.parent_path() / newName;
|
||||
if (dest == src) return true; // unchanged
|
||||
if (fs::exists(dest, ec)) {
|
||||
status_ = "An item with that name already exists";
|
||||
return false;
|
||||
}
|
||||
fs::rename(src, dest, ec);
|
||||
if (ec) {
|
||||
status_ = "Rename failed: " + ec.message();
|
||||
return false;
|
||||
}
|
||||
status_ = "Renamed to " + newName;
|
||||
beginScan(location_, inSmb_);
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string Browser::parentDir() const {
|
||||
if (location_ == "bookmarks://") return homeDir_;
|
||||
if (location_.empty() || location_ == "/" || inSmb_) return "";
|
||||
fs::path p(location_);
|
||||
return p.parent_path().string();
|
||||
}
|
||||
|
||||
std::string Browser::homeDir() const {
|
||||
const char* h = std::getenv("HOME");
|
||||
return h ? std::string(h) : "/";
|
||||
}
|
||||
|
||||
std::string Browser::videosDir() const {
|
||||
std::string d = homeDir() + "/Videos";
|
||||
std::error_code ec;
|
||||
return fs::is_directory(d, ec) ? d : homeDir();
|
||||
}
|
||||
|
||||
std::string Browser::downloadsDir() const {
|
||||
std::string d = homeDir() + "/Downloads";
|
||||
std::error_code ec;
|
||||
return fs::is_directory(d, ec) ? d : homeDir();
|
||||
}
|
||||
|
||||
void Browser::runScan(std::string location, bool isSmb) {
|
||||
ScanResult r;
|
||||
if (isSmb) {
|
||||
if (location == "bookmarks://") {
|
||||
scanBookmarksInto(r);
|
||||
} else if (isSmb) {
|
||||
scanSmbInto(location, r);
|
||||
} else {
|
||||
scanLocalInto(location, r);
|
||||
@@ -329,6 +762,10 @@ Browser::Result Browser::activate(std::string& playPath) {
|
||||
const Entry e = entries_[static_cast<std::size_t>(selected_)];
|
||||
|
||||
if (e.isBack) {
|
||||
if (location_ == "bookmarks://") {
|
||||
beginScan(homeDir_, false);
|
||||
return Result::Navigated;
|
||||
}
|
||||
if (inSmb_) {
|
||||
const std::string parent = smb_parent(location_);
|
||||
if (parent.empty()) {
|
||||
@@ -337,17 +774,30 @@ Browser::Result Browser::activate(std::string& playPath) {
|
||||
beginScan(parent, true);
|
||||
}
|
||||
} else {
|
||||
const fs::path p(location_);
|
||||
beginScan(p.has_parent_path() ? p.parent_path().string() : "/", false);
|
||||
if (VaultManager::instance().isVaultPath(location_)) {
|
||||
if (location_ == VaultManager::instance().vaultDir()) {
|
||||
beginScan(homeDir_, false);
|
||||
} else {
|
||||
const fs::path p(location_);
|
||||
beginScan(p.has_parent_path() ? p.parent_path().string() : homeDir_, false);
|
||||
}
|
||||
} else {
|
||||
const fs::path p(location_);
|
||||
beginScan(p.has_parent_path() ? p.parent_path().string() : "/", false);
|
||||
}
|
||||
}
|
||||
return Result::Navigated;
|
||||
}
|
||||
if (e.path == "smb://__add_share__") {
|
||||
return Result::AddSmbShare;
|
||||
}
|
||||
if (e.isNetwork) {
|
||||
beginScan("smb://", true);
|
||||
return Result::Navigated;
|
||||
}
|
||||
if (e.isDir) {
|
||||
beginScan(e.path, inSmb_);
|
||||
const bool isSmb = e.isNetwork || (e.path.rfind("smb://", 0) == 0);
|
||||
beginScan(e.path, isSmb);
|
||||
return Result::Navigated;
|
||||
}
|
||||
if (e.isVideo) {
|
||||
@@ -362,7 +812,7 @@ Browser::Result Browser::activate(std::string& playPath) {
|
||||
}
|
||||
|
||||
bool Browser::downloadFocused() {
|
||||
if (dlActive_.load() || selected_ < 0 ||
|
||||
if (selected_ < 0 ||
|
||||
selected_ >= static_cast<int>(entries_.size())) {
|
||||
return false;
|
||||
}
|
||||
@@ -393,19 +843,36 @@ Browser::~Browser() {
|
||||
|
||||
void Browser::startDownload(const std::string& uri, const std::string& nameIn,
|
||||
bool isDir) {
|
||||
if (dlActive_.load()) return;
|
||||
std::string name = nameIn;
|
||||
while (!name.empty() && name.back() == '/') name.pop_back();
|
||||
const std::size_t slash = name.find_last_of('/');
|
||||
if (slash != std::string::npos) name = name.substr(slash + 1);
|
||||
if (name.empty()) return;
|
||||
dlName_ = name;
|
||||
|
||||
for (const auto& j : dlJobs_) {
|
||||
if (j.uri == uri && (j.state == 0 || j.state == 4)) {
|
||||
status_ = "Already downloading or in queue: " + name;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const std::string destDir =
|
||||
downloadDir_.empty() ? homeDir_ + "/media" : downloadDir_;
|
||||
const std::string targetPath = destDir + "/" + name;
|
||||
|
||||
if (dlActive_.load()) {
|
||||
dlQueue_.push_back({uri, name, targetPath, isDir});
|
||||
dlJobs_.push_back({name, targetPath, uri, 4, 0, 0, isDir});
|
||||
status_ = "Added to download queue: " + name;
|
||||
PIXELVR_LOG_INFO("Added to download queue: %s (%s)", name.c_str(), uri.c_str());
|
||||
return;
|
||||
}
|
||||
|
||||
dlName_ = name;
|
||||
if (isDir) {
|
||||
startDownloadFolder(uri, destDir + "/" + name);
|
||||
startDownloadFolder(uri, targetPath);
|
||||
} else {
|
||||
startDownloadFile(uri, destDir + "/" + name);
|
||||
startDownloadFile(uri, targetPath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -433,11 +900,33 @@ void Browser::startDownloadFile(const std::string& uri, const std::string& local
|
||||
dlActive_.store(true);
|
||||
status_ = "Downloading " + dlName_ + " ...";
|
||||
PIXELVR_LOG_INFO("Download file: %s -> %s", uri.c_str(), local.c_str());
|
||||
dlJobs_.push_back({dlName_, local, uri, 0, 0, 0, false});
|
||||
dlActiveJob_ = static_cast<int>(dlJobs_.size()) - 1;
|
||||
if (dlJobs_.size() > 12) {
|
||||
dlJobs_.erase(dlJobs_.begin());
|
||||
|
||||
int existingIdx = -1;
|
||||
for (std::size_t i = 0; i < dlJobs_.size(); ++i) {
|
||||
if (dlJobs_[i].uri == uri && (dlJobs_[i].state == 4 || dlJobs_[i].state == 0)) {
|
||||
existingIdx = static_cast<int>(i);
|
||||
dlJobs_[i].state = 0;
|
||||
dlJobs_[i].name = dlName_;
|
||||
dlJobs_[i].localPath = local;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (existingIdx >= 0) {
|
||||
dlActiveJob_ = existingIdx;
|
||||
} else {
|
||||
dlJobs_.push_back({dlName_, local, uri, 0, 0, 0, false});
|
||||
dlActiveJob_ = static_cast<int>(dlJobs_.size()) - 1;
|
||||
if (dlJobs_.size() > 16) {
|
||||
for (auto it = dlJobs_.begin(); it != dlJobs_.end(); ++it) {
|
||||
if (it->state != 0 && it->state != 4) {
|
||||
dlJobs_.erase(it);
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (std::size_t i = 0; i < dlJobs_.size(); ++i) {
|
||||
if (dlJobs_[i].state == 0) dlActiveJob_ = static_cast<int>(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
const std::string name = dlName_;
|
||||
dlLocalPath_ = local;
|
||||
@@ -509,6 +998,8 @@ int Browser::spawnCurl(const std::string& uri, const std::string& local) {
|
||||
const std::size_t s2 =
|
||||
(s1 == std::string::npos) ? std::string::npos : rest.find('/', s1 + 1);
|
||||
if (s2 == std::string::npos) return -1;
|
||||
const std::string host = rest.substr(0, s1);
|
||||
const std::string share = rest.substr(s1 + 1, s2 - s1 - 1);
|
||||
const std::string service = "//" + rest.substr(0, s2);
|
||||
const std::string remote = rest.substr(s2 + 1);
|
||||
std::string cmd = "get \"";
|
||||
@@ -516,14 +1007,26 @@ int Browser::spawnCurl(const std::string& uri, const std::string& local) {
|
||||
cmd += "\" \"";
|
||||
cmd += local;
|
||||
cmd += "\"";
|
||||
PIXELVR_LOG_INFO("DL spawn: smbclient %s -N -c '%s'", service.c_str(),
|
||||
cmd.c_str());
|
||||
char* argv[] = {const_cast<char*>("smbclient"),
|
||||
const_cast<char*>(service.c_str()),
|
||||
const_cast<char*>("-N"),
|
||||
const_cast<char*>("-c"),
|
||||
const_cast<char*>(cmd.c_str()),
|
||||
nullptr};
|
||||
|
||||
std::string authFile = SmbCredentialsManager::instance().getAuthFilePath(host, share);
|
||||
PIXELVR_LOG_INFO("DL spawn: smbclient %s %s -c '%s'", service.c_str(),
|
||||
authFile.empty() ? "-N" : ("-A " + authFile).c_str(), cmd.c_str());
|
||||
|
||||
char* argv[8] = {nullptr};
|
||||
argv[0] = const_cast<char*>("smbclient");
|
||||
argv[1] = const_cast<char*>(service.c_str());
|
||||
if (!authFile.empty()) {
|
||||
argv[2] = const_cast<char*>("-A");
|
||||
argv[3] = const_cast<char*>(authFile.c_str());
|
||||
argv[4] = const_cast<char*>("-c");
|
||||
argv[5] = const_cast<char*>(cmd.c_str());
|
||||
argv[6] = nullptr;
|
||||
} else {
|
||||
argv[2] = const_cast<char*>("-N");
|
||||
argv[3] = const_cast<char*>("-c");
|
||||
argv[4] = const_cast<char*>(cmd.c_str());
|
||||
argv[5] = nullptr;
|
||||
}
|
||||
posix_spawn_file_actions_t fa;
|
||||
posix_spawn_file_actions_init(&fa);
|
||||
// Take stdin from /dev/null so smbclient can never block on an auth prompt.
|
||||
@@ -557,11 +1060,32 @@ void Browser::startDownloadFolder(const std::string& uri,
|
||||
dlStopped_.store(false);
|
||||
dlPaused_.store(false);
|
||||
dlAbort_.store(false);
|
||||
dlJobs_.push_back({dlName_, destDir, uri, 0, 0, 0, true});
|
||||
dlActiveJob_ = static_cast<int>(dlJobs_.size()) - 1;
|
||||
if (dlJobs_.size() > 12) {
|
||||
dlJobs_.erase(dlJobs_.begin());
|
||||
int existingIdx = -1;
|
||||
for (std::size_t i = 0; i < dlJobs_.size(); ++i) {
|
||||
if (dlJobs_[i].uri == uri && (dlJobs_[i].state == 4 || dlJobs_[i].state == 0)) {
|
||||
existingIdx = static_cast<int>(i);
|
||||
dlJobs_[i].state = 0;
|
||||
dlJobs_[i].name = dlName_;
|
||||
dlJobs_[i].localPath = destDir;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (existingIdx >= 0) {
|
||||
dlActiveJob_ = existingIdx;
|
||||
} else {
|
||||
dlJobs_.push_back({dlName_, destDir, uri, 0, 0, 0, true});
|
||||
dlActiveJob_ = static_cast<int>(dlJobs_.size()) - 1;
|
||||
if (dlJobs_.size() > 16) {
|
||||
for (auto it = dlJobs_.begin(); it != dlJobs_.end(); ++it) {
|
||||
if (it->state != 0 && it->state != 4) {
|
||||
dlJobs_.erase(it);
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (std::size_t i = 0; i < dlJobs_.size(); ++i) {
|
||||
if (dlJobs_[i].state == 0) dlActiveJob_ = static_cast<int>(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
dlThread_ = std::thread([this, uri, destDir]() {
|
||||
std::error_code ec;
|
||||
@@ -734,8 +1258,8 @@ void Browser::clearFinished() {
|
||||
std::vector<DownloadJob> keep;
|
||||
keep.reserve(dlJobs_.size());
|
||||
for (const DownloadJob& j : dlJobs_) {
|
||||
if (j.state == 0) {
|
||||
keep.push_back(j); // keep the in-flight job
|
||||
if (j.state == 0 || j.state == 4) {
|
||||
keep.push_back(j); // keep the in-flight and queued jobs
|
||||
continue;
|
||||
}
|
||||
// Remove partial files from stopped/failed single-file downloads.
|
||||
@@ -752,6 +1276,38 @@ void Browser::clearFinished() {
|
||||
status_ = "Cleared finished downloads";
|
||||
}
|
||||
|
||||
void Browser::cancelQueued(const std::string& uri) {
|
||||
for (auto it = dlQueue_.begin(); it != dlQueue_.end(); ++it) {
|
||||
if (it->uri == uri) {
|
||||
dlQueue_.erase(it);
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (auto it = dlJobs_.begin(); it != dlJobs_.end(); ++it) {
|
||||
if (it->uri == uri && it->state == 4) {
|
||||
dlJobs_.erase(it);
|
||||
break;
|
||||
}
|
||||
}
|
||||
status_ = "Removed from download queue";
|
||||
}
|
||||
|
||||
void Browser::startNextQueued() {
|
||||
if (dlActive_.load()) return;
|
||||
if (dlQueue_.empty()) return;
|
||||
|
||||
DownloadQueueItem item = dlQueue_.front();
|
||||
dlQueue_.pop_front();
|
||||
|
||||
dlName_ = item.name;
|
||||
PIXELVR_LOG_INFO("Starting next queued download: %s (%s)", item.name.c_str(), item.uri.c_str());
|
||||
if (item.isDir) {
|
||||
startDownloadFolder(item.uri, item.destDir);
|
||||
} else {
|
||||
startDownloadFile(item.uri, item.destDir);
|
||||
}
|
||||
}
|
||||
|
||||
void Browser::measureFolder(const std::string& uri) {
|
||||
#ifdef PIXELVR_HAVE_SMB
|
||||
if (measuring_.load()) return;
|
||||
@@ -889,6 +1445,7 @@ Browser::Result Browser::poll(std::string& playPath) {
|
||||
} else {
|
||||
status_ = "Download failed: " + dlName_;
|
||||
}
|
||||
startNextQueued();
|
||||
}
|
||||
return Result::None;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <deque>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
@@ -23,8 +24,9 @@ public:
|
||||
bool isNetwork = false; // "Network (SMB)" entry point
|
||||
std::string detail; // human-readable type/size for the row
|
||||
long long size = 0; // bytes (0 if unknown)
|
||||
std::string previewPath; // video file inside folder or self (for thumbnail)
|
||||
};
|
||||
enum class Result { None, Navigated, Play };
|
||||
enum class Result { None, Navigated, Play, AddSmbShare };
|
||||
|
||||
Browser();
|
||||
|
||||
@@ -33,6 +35,42 @@ public:
|
||||
// Opens the browser directly at a local directory (used when a finished
|
||||
// folder download is selected).
|
||||
void openPath(const std::string& localDir);
|
||||
// Direct shortcut navigation (for spatial browser sidebar rail)
|
||||
void openNetwork();
|
||||
void openHome();
|
||||
void openVideos();
|
||||
void openDownloads();
|
||||
void openDrives();
|
||||
void openVault();
|
||||
struct Bookmark {
|
||||
std::string path;
|
||||
std::string label;
|
||||
};
|
||||
void openBookmarks();
|
||||
void addBookmark(const std::string& path, const std::string& label = "");
|
||||
void removeBookmark(const std::string& path);
|
||||
bool isBookmarked(const std::string& path) const;
|
||||
bool isCurrentBookmarked() const { return isBookmarked(location_); }
|
||||
const std::vector<Bookmark>& bookmarks() const { return bookmarks_; }
|
||||
void loadBookmarks();
|
||||
void saveBookmarks() const;
|
||||
bool onBookmarks() const { return location_ == "bookmarks://"; }
|
||||
|
||||
void openQuickPath() { openBookmarks(); }
|
||||
void setQuickPath(const std::string& path) { addBookmark(path); }
|
||||
const std::string& quickPath() const { return quickPath_; }
|
||||
bool isCurrentQuickPath() const { return isCurrentBookmarked(); }
|
||||
bool inVault() const;
|
||||
bool moveFocusedToVault(std::string& outNewPath);
|
||||
bool moveFocusedTo(const std::string& destDir, std::string& outNewPath);
|
||||
bool deleteFocused(std::string& deletedName);
|
||||
bool renameFocused(const std::string& newName);
|
||||
const std::string& currentLocation() const { return location_; }
|
||||
const std::string& currentPath() const { return location_; }
|
||||
std::string parentDir() const;
|
||||
std::string homeDir() const;
|
||||
std::string videosDir() const;
|
||||
std::string downloadsDir() const;
|
||||
bool isOpen() const { return open_; }
|
||||
// The download worker yields the shared SMB connection while the browser is
|
||||
// open, so directory listing stays responsive (downloads resume afterwards).
|
||||
@@ -97,13 +135,21 @@ public:
|
||||
std::string name;
|
||||
std::string localPath;
|
||||
std::string uri;
|
||||
int state = 0; // 0 downloading, 1 done, 2 failed, 3 stopped
|
||||
int state = 0; // 0 downloading, 1 done, 2 failed, 3 stopped, 4 queued
|
||||
long long bytes = 0;
|
||||
long long total = 0;
|
||||
bool isDir = false;
|
||||
};
|
||||
struct DownloadQueueItem {
|
||||
std::string uri;
|
||||
std::string name;
|
||||
std::string destDir;
|
||||
bool isDir = false;
|
||||
};
|
||||
const std::vector<DownloadJob>& downloadJobs() const { return dlJobs_; }
|
||||
bool hasDownloads() const { return !dlJobs_.empty(); }
|
||||
bool hasQueuedDownloads() const { return !dlQueue_.empty(); }
|
||||
void cancelQueued(const std::string& uri);
|
||||
bool downloadPaused() const { return dlPaused_.load(); }
|
||||
void togglePause(); // SIGSTOP/SIGCONT the curl child
|
||||
void stopDownload(); // SIGKILL the child and drop the partial file
|
||||
@@ -137,8 +183,10 @@ private:
|
||||
bool hasPendingScan_ = false;
|
||||
void scanLocalInto(const std::string& dir, ScanResult& out);
|
||||
void scanSmbInto(const std::string& uri, ScanResult& out);
|
||||
void scanBookmarksInto(ScanResult& out);
|
||||
void startDownloadFile(const std::string& uri, const std::string& local);
|
||||
void startDownloadFolder(const std::string& uri, const std::string& name);
|
||||
void startNextQueued();
|
||||
|
||||
bool open_ = false;
|
||||
bool inSmb_ = false;
|
||||
@@ -146,6 +194,8 @@ private:
|
||||
std::string status_;
|
||||
std::string location_; // current local dir or smb:// URI
|
||||
std::string homeDir_;
|
||||
std::string quickPath_; // bookmarked folder for one-button jump
|
||||
std::vector<Bookmark> bookmarks_;
|
||||
std::string lastLocation_; // last folder visited (local or smb), for re-open
|
||||
bool lastInSmb_ = false;
|
||||
std::vector<Entry> entries_;
|
||||
@@ -169,6 +219,7 @@ private:
|
||||
std::string dlName_;
|
||||
std::string downloadDir_; // "" => default ~/media
|
||||
std::vector<DownloadJob> dlJobs_;
|
||||
std::deque<DownloadQueueItem> dlQueue_;
|
||||
int dlActiveJob_ = -1; // index of the job in flight, or -1
|
||||
std::atomic<bool> dlPaused_{false};
|
||||
std::atomic<bool> dlAbort_{false};
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
#include "ui/settings_menu.hpp"
|
||||
#include "app/vault_manager.hpp"
|
||||
#include "net/smb_credentials.hpp"
|
||||
#include "xr/vr_keyboard.hpp"
|
||||
|
||||
#include "util/logging.hpp"
|
||||
|
||||
@@ -29,6 +32,30 @@ const char* kLangCodes[] = {"", "eng", "deu", "fra", "spa",
|
||||
"jpn", "ita", "rus", "chi", "kor"};
|
||||
constexpr int kLangCount = 10;
|
||||
|
||||
const char* kCurvatureNames[] = {"Flat (0%)", "Gentle (25%)", "Cinema (50%)",
|
||||
"Deep Curve (75%)", "IMAX Wrap (100%)"};
|
||||
const float kCurvatureValues[] = {0.0f, 0.25f, 0.50f, 0.75f, 1.00f};
|
||||
constexpr int kCurvatureCount = 5;
|
||||
|
||||
const char* kUiTextScaleNames[] = {"Compact (90%)", "Standard (105%)",
|
||||
"Large (120%)", "Extra Large (140%)",
|
||||
"Huge (160%)"};
|
||||
const float kUiTextScaleValues[] = {0.90f, 1.05f, 1.20f, 1.40f, 1.60f};
|
||||
constexpr int kUiTextScaleCount = 5;
|
||||
|
||||
const char* kUiTextColorNames[] = {"Pure White", "Ice Cyan", "Warm Cream",
|
||||
"Soft Gold", "Mint Green"};
|
||||
constexpr int kUiTextColorCount = 5;
|
||||
|
||||
const char* kUiAccentColorNames[] = {"Cyber Cyan", "Emerald Green", "Amber Gold",
|
||||
"Royal Purple", "Crimson Red"};
|
||||
constexpr int kUiAccentColorCount = 5;
|
||||
|
||||
const char* kUiDistanceNames[] = {"Near (1.5 m)", "Comfort (1.9 m)",
|
||||
"Medium (2.3 m)", "Far (2.8 m)"};
|
||||
const float kUiDistanceValues[] = {1.5f, 1.9f, 2.3f, 2.8f};
|
||||
constexpr int kUiDistanceCount = 4;
|
||||
|
||||
std::string getConfigPath() {
|
||||
#ifdef _WIN32
|
||||
const char* appData = std::getenv("APPDATA");
|
||||
@@ -53,6 +80,7 @@ SettingsMenu::SettingsMenu() {
|
||||
|
||||
void SettingsMenu::open() {
|
||||
open_ = true;
|
||||
if (section_ < 0 || (section_ > 5 && section_ != 20)) section_ = 0;
|
||||
refresh();
|
||||
}
|
||||
|
||||
@@ -81,7 +109,7 @@ void SettingsMenu::refresh() {
|
||||
}
|
||||
|
||||
if (section_ == 0) {
|
||||
title_ = "Display & Projection";
|
||||
title_ = "Settings > Display & Cinema";
|
||||
const char* projNames[] = {"Flat Screen", "360 Equirectangular",
|
||||
"180 Equirectangular"};
|
||||
const char* stereoNames[] = {"2D Mono", "Side-by-Side (SBS)",
|
||||
@@ -94,28 +122,46 @@ void SettingsMenu::refresh() {
|
||||
Action::CycleProjection});
|
||||
rows_.push_back({"Stereo 3D Format", stereoNames[stereoMode_ % 3],
|
||||
Action::CycleStereo});
|
||||
rows_.push_back({"Swap Left/Right Eye", swapEyes_ ? "On" : "Off",
|
||||
Action::ToggleSwapEyes});
|
||||
rows_.push_back({"Cinema Screen Curve", kCurvatureNames[curvatureIdx_ % kCurvatureCount],
|
||||
Action::CycleCurvature});
|
||||
rows_.push_back({"Screen Distance", distStream.str(),
|
||||
Action::CycleDistance});
|
||||
rows_.push_back({"Swap Left/Right Eye", swapEyes_ ? "On" : "Off",
|
||||
Action::ToggleSwapEyes});
|
||||
rows_.push_back({"Recenter Screen View", "Recenter Now",
|
||||
Action::RecenterScreen});
|
||||
} else if (section_ == 1) {
|
||||
title_ = "Controls & Bindings";
|
||||
auto btnLabel = [&](int func, int btn) -> std::string {
|
||||
return listeningFunc_ == func
|
||||
? std::string("[ press a button ]")
|
||||
: std::string(kButtonNames[btn % kButtonCount]);
|
||||
};
|
||||
rows_.push_back({"Play / Pause", btnLabel(0, bindPlayBtn_),
|
||||
Action::ListenPlay});
|
||||
rows_.push_back({"Open Menu / Browser", btnLabel(1, bindMenuBtn_),
|
||||
Action::ListenMenu});
|
||||
rows_.push_back({"Recenter View", btnLabel(2, bindRecenterBtn_),
|
||||
Action::ListenRecenter});
|
||||
rows_.push_back({"Select / Click", "Trigger", Action::None});
|
||||
rows_.push_back({"Controller Models", drawModels_ ? "On" : "Off",
|
||||
Action::ToggleModels});
|
||||
title_ = "Settings > UI & Appearance";
|
||||
rows_.push_back({"UI Text Size",
|
||||
kUiTextScaleNames[uiTextScaleIdx_ % kUiTextScaleCount],
|
||||
Action::CycleUiTextScale});
|
||||
rows_.push_back({"UI Text Color",
|
||||
kUiTextColorNames[uiTextColorIdx_ % kUiTextColorCount],
|
||||
Action::CycleUiTextColor});
|
||||
rows_.push_back({"UI Accent Theme",
|
||||
kUiAccentColorNames[uiAccentColorIdx_ % kUiAccentColorCount],
|
||||
Action::CycleUiAccentColor});
|
||||
rows_.push_back({"UI Panel Distance",
|
||||
kUiDistanceNames[uiDistanceIdx_ % kUiDistanceCount],
|
||||
Action::CycleUiDistance});
|
||||
const char* followModeNames[2] = {"World Lock (Fixed)", "Smooth Gaze Follow"};
|
||||
rows_.push_back({"UI Follow Mode",
|
||||
followModeNames[uiFollowMode_ % 2],
|
||||
Action::CycleUiFollowMode});
|
||||
const char* viewModeNames[3] = {"Large Cards (3x2)", "Smaller Cards (4x3)", "Compact List (1-Col)"};
|
||||
rows_.push_back({"Browser View Mode",
|
||||
viewModeNames[browserViewMode_ % 3],
|
||||
Action::CycleBrowserViewMode});
|
||||
rows_.push_back({"Configure List Columns",
|
||||
"[ Customize Columns > ]",
|
||||
Action::EnterListColsConfig});
|
||||
rows_.push_back({"Download Folder",
|
||||
kDownloadDirs[downloadDirIdx_ % kDownloadDirCount],
|
||||
Action::CycleDownloadDir});
|
||||
rows_.push_back({"Debug Statistics", showStats_ ? "On" : "Off",
|
||||
Action::ToggleStats});
|
||||
} else if (section_ == 2) {
|
||||
title_ = "Audio / Video Tracks";
|
||||
title_ = "Settings > Audio & Video Tracks";
|
||||
Row v{"Video Track",
|
||||
videoStreams_.empty()
|
||||
? "(none)"
|
||||
@@ -138,40 +184,94 @@ void SettingsMenu::refresh() {
|
||||
kLangNames[audioLangIdx_ % kLangCount],
|
||||
Action::EnterLangList});
|
||||
} else if (section_ == 3) {
|
||||
title_ = "System & Debug";
|
||||
rows_.push_back({"Debug Statistics", showStats_ ? "On" : "Off",
|
||||
Action::ToggleStats});
|
||||
rows_.push_back({"Download Folder",
|
||||
kDownloadDirs[downloadDirIdx_ % kDownloadDirCount],
|
||||
Action::CycleDownloadDir});
|
||||
title_ = "Settings > Controls & Bindings";
|
||||
auto btnLabel = [&](int func, int btn) -> std::string {
|
||||
return listeningFunc_ == func
|
||||
? std::string("[ press a button ]")
|
||||
: std::string(kButtonNames[btn % kButtonCount]);
|
||||
};
|
||||
rows_.push_back({"Play / Pause", btnLabel(0, bindPlayBtn_),
|
||||
Action::ListenPlay});
|
||||
rows_.push_back({"Open Menu / Browser", btnLabel(1, bindMenuBtn_),
|
||||
Action::ListenMenu});
|
||||
rows_.push_back({"Recenter View", btnLabel(2, bindRecenterBtn_),
|
||||
Action::ListenRecenter});
|
||||
rows_.push_back({"Bookmarks Shortcut", btnLabel(4, bindQuickPathBtn_),
|
||||
Action::ListenQuickPath});
|
||||
rows_.push_back({"Select / Click", "Trigger", Action::None});
|
||||
rows_.push_back({"Controller Models", drawModels_ ? "On" : "Off",
|
||||
Action::ToggleModels});
|
||||
} else if (section_ == 4) {
|
||||
title_ = "Settings > Vault & Privacy";
|
||||
rows_.push_back({"Auto-Lock on Headset Removal",
|
||||
VaultManager::instance().autoLockHeadsetOff() ? "Enabled" : "Disabled",
|
||||
Action::CycleVaultAutoLockHeadset});
|
||||
|
||||
int toSec = VaultManager::instance().autoLockTimeoutSec();
|
||||
std::string toStr = "Disabled";
|
||||
if (toSec == 120) toStr = "2 Minutes";
|
||||
else if (toSec == 300) toStr = "5 Minutes";
|
||||
else if (toSec == 600) toStr = "10 Minutes";
|
||||
rows_.push_back({"Inactivity Auto-Lock Timeout", toStr,
|
||||
Action::CycleVaultTimeout});
|
||||
|
||||
auto moveBtnLabel = [&]() -> std::string {
|
||||
if (listeningFunc_ == 3) return "[ press a button ]";
|
||||
int mb = VaultManager::instance().moveButton();
|
||||
if (mb < 0 || mb >= kButtonCount) return "Disabled";
|
||||
return kButtonNames[mb % kButtonCount];
|
||||
};
|
||||
rows_.push_back({"File Actions Button", moveBtnLabel(),
|
||||
Action::ListenVaultMoveBtn});
|
||||
|
||||
std::string comboLabel;
|
||||
if (VaultManager::instance().isRecordingCombo()) {
|
||||
comboLabel = "[ Recording: " + std::to_string(VaultManager::instance().recordedCount()) + "/6 ]";
|
||||
} else {
|
||||
comboLabel = VaultManager::instance().comboString();
|
||||
}
|
||||
rows_.push_back({"Unlock Combo Sequence", comboLabel,
|
||||
Action::RecordVaultCombo});
|
||||
|
||||
rows_.push_back({"Lock Vault Now", "[ Lock & Return Home ]",
|
||||
Action::LockVaultAction});
|
||||
} else if (section_ == 21) {
|
||||
title_ = "Settings > List Columns";
|
||||
rows_.push_back({"Format / VR Badge", listShowFormat_ ? "Visible" : "Hidden", Action::ToggleListColFormat});
|
||||
rows_.push_back({"Resolution & FPS", listShowRes_ ? "Visible" : "Hidden", Action::ToggleListColRes});
|
||||
rows_.push_back({"File Size", listShowSize_ ? "Visible" : "Hidden", Action::ToggleListColSize});
|
||||
rows_.push_back({"Video Duration", listShowDuration_ ? "Visible" : "Hidden", Action::ToggleListColDuration});
|
||||
rows_.push_back({"< Back to UI & Appearance", "", Action::BackToUI});
|
||||
} else if (section_ == 10) {
|
||||
title_ = "Select Video Track";
|
||||
title_ = "Settings > Select Video Track";
|
||||
for (std::size_t i = 0; i < videoStreams_.size(); ++i) {
|
||||
Row r{videoStreams_[i].second,
|
||||
static_cast<int>(i) == videoSel_ ? "<" : "",
|
||||
static_cast<int>(i) == videoSel_ ? "ACTIVE" : "Select",
|
||||
Action::PickVideoStream};
|
||||
r.data = static_cast<int>(i);
|
||||
rows_.push_back(r);
|
||||
}
|
||||
rows_.push_back({"< Back to Tracks", "", Action::Back});
|
||||
} else if (section_ == 11) {
|
||||
title_ = "Select Audio Track";
|
||||
title_ = "Settings > Select Audio Track";
|
||||
for (std::size_t i = 0; i < audioStreams_.size(); ++i) {
|
||||
Row r{audioStreams_[i].second,
|
||||
static_cast<int>(i) == audioSel_ ? "<" : "",
|
||||
static_cast<int>(i) == audioSel_ ? "ACTIVE" : "Select",
|
||||
Action::PickAudioStream};
|
||||
r.data = static_cast<int>(i);
|
||||
rows_.push_back(r);
|
||||
}
|
||||
rows_.push_back({"< Back to Tracks", "", Action::Back});
|
||||
} else { // section_ == 12: preferred-language list
|
||||
title_ = "Preferred Audio Language";
|
||||
title_ = "Settings > Preferred Audio Language";
|
||||
for (int i = 0; i < kLangCount; ++i) {
|
||||
Row r{kLangNames[i], i == audioLangIdx_ ? "<" : "",
|
||||
Row r{kLangNames[i], i == audioLangIdx_ ? "ACTIVE" : "Select",
|
||||
Action::PickLanguage};
|
||||
r.data = i;
|
||||
rows_.push_back(r);
|
||||
}
|
||||
rows_.push_back({"< Back to Tracks", "", Action::Back});
|
||||
}
|
||||
rows_.push_back({"< Back", "", Action::Back});
|
||||
}
|
||||
|
||||
SettingsMenu::Action SettingsMenu::activate() {
|
||||
@@ -188,7 +288,7 @@ SettingsMenu::Action SettingsMenu::activate() {
|
||||
case Action::EnterAudioList: section_ = 11; selected_ = 0; refresh(); break;
|
||||
case Action::EnterLangList: section_ = 12; selected_ = 0; refresh(); break;
|
||||
case Action::Back:
|
||||
section_ = (section_ >= 10) ? 2 : -1; // list -> Tracks; else -> root
|
||||
section_ = (section_ >= 10) ? 2 : 0; // list -> Tracks; else -> Display
|
||||
selected_ = 0;
|
||||
refresh();
|
||||
break;
|
||||
@@ -243,6 +343,46 @@ SettingsMenu::Action SettingsMenu::activate() {
|
||||
saveConfig();
|
||||
refresh();
|
||||
break;
|
||||
case Action::CycleCurvature:
|
||||
curvatureIdx_ = (curvatureIdx_ + 1) % kCurvatureCount;
|
||||
saveConfig();
|
||||
refresh();
|
||||
break;
|
||||
case Action::CycleUiTextScale:
|
||||
uiTextScaleIdx_ = (uiTextScaleIdx_ + 1) % kUiTextScaleCount;
|
||||
saveConfig();
|
||||
refresh();
|
||||
break;
|
||||
case Action::CycleUiTextColor:
|
||||
uiTextColorIdx_ = (uiTextColorIdx_ + 1) % kUiTextColorCount;
|
||||
saveConfig();
|
||||
refresh();
|
||||
break;
|
||||
case Action::CycleUiAccentColor:
|
||||
uiAccentColorIdx_ = (uiAccentColorIdx_ + 1) % kUiAccentColorCount;
|
||||
saveConfig();
|
||||
refresh();
|
||||
break;
|
||||
case Action::CycleUiDistance:
|
||||
uiDistanceIdx_ = (uiDistanceIdx_ + 1) % kUiDistanceCount;
|
||||
saveConfig();
|
||||
refresh();
|
||||
break;
|
||||
case Action::CycleUiFollowMode:
|
||||
uiFollowMode_ = (uiFollowMode_ + 1) % 2;
|
||||
saveConfig();
|
||||
refresh();
|
||||
break;
|
||||
case Action::CycleBrowserViewMode:
|
||||
browserViewMode_ = (browserViewMode_ + 1) % 3;
|
||||
saveConfig();
|
||||
refresh();
|
||||
break;
|
||||
case Action::ListenQuickPath:
|
||||
listeningFunc_ = 4;
|
||||
status_ = "Press a controller button for Bookmarks shortcut";
|
||||
refresh();
|
||||
break;
|
||||
case Action::PickVideoStream:
|
||||
videoSel_ = rows_[selected_].data;
|
||||
section_ = 2;
|
||||
@@ -262,6 +402,71 @@ SettingsMenu::Action SettingsMenu::activate() {
|
||||
selected_ = 0;
|
||||
refresh();
|
||||
break;
|
||||
case Action::CycleVaultAutoLockHeadset:
|
||||
VaultManager::instance().setAutoLockHeadsetOff(!VaultManager::instance().autoLockHeadsetOff());
|
||||
refresh();
|
||||
break;
|
||||
case Action::CycleVaultTimeout: {
|
||||
int to = VaultManager::instance().autoLockTimeoutSec();
|
||||
if (to == 0) to = 120;
|
||||
else if (to == 120) to = 300;
|
||||
else if (to == 300) to = 600;
|
||||
else to = 0;
|
||||
VaultManager::instance().setAutoLockTimeoutSec(to);
|
||||
refresh();
|
||||
break;
|
||||
}
|
||||
case Action::ListenVaultMoveBtn:
|
||||
listeningFunc_ = 3;
|
||||
status_ = "Press a controller button for Move-to-Vault";
|
||||
refresh();
|
||||
break;
|
||||
case Action::RecordVaultCombo:
|
||||
if (VaultManager::instance().isRecordingCombo()) {
|
||||
VaultManager::instance().cancelRecordingCombo();
|
||||
status_ = "Combo recording cancelled";
|
||||
} else {
|
||||
VaultManager::instance().startRecordingCombo();
|
||||
status_ = "Press 6 face buttons (A, B, X, Y) to record new combo";
|
||||
}
|
||||
refresh();
|
||||
break;
|
||||
case Action::LockVaultAction:
|
||||
VaultManager::instance().lock();
|
||||
section_ = 0;
|
||||
refresh();
|
||||
break;
|
||||
|
||||
case Action::EnterListColsConfig:
|
||||
section_ = 21;
|
||||
selected_ = 0;
|
||||
refresh();
|
||||
break;
|
||||
case Action::ToggleListColFormat:
|
||||
listShowFormat_ = !listShowFormat_;
|
||||
refresh();
|
||||
saveConfig();
|
||||
break;
|
||||
case Action::ToggleListColRes:
|
||||
listShowRes_ = !listShowRes_;
|
||||
refresh();
|
||||
saveConfig();
|
||||
break;
|
||||
case Action::ToggleListColSize:
|
||||
listShowSize_ = !listShowSize_;
|
||||
refresh();
|
||||
saveConfig();
|
||||
break;
|
||||
case Action::ToggleListColDuration:
|
||||
listShowDuration_ = !listShowDuration_;
|
||||
refresh();
|
||||
saveConfig();
|
||||
break;
|
||||
case Action::BackToUI:
|
||||
section_ = 1;
|
||||
selected_ = 0;
|
||||
refresh();
|
||||
break;
|
||||
case Action::RecenterScreen:
|
||||
break;
|
||||
case Action::Close:
|
||||
@@ -310,6 +515,8 @@ void SettingsMenu::assignListening(int button) {
|
||||
if (listeningFunc_ == 0) bindPlayBtn_ = b;
|
||||
else if (listeningFunc_ == 1) bindMenuBtn_ = b;
|
||||
else if (listeningFunc_ == 2) bindRecenterBtn_ = b;
|
||||
else if (listeningFunc_ == 3) VaultManager::instance().setMoveButton(b);
|
||||
else if (listeningFunc_ == 4) bindQuickPathBtn_ = b;
|
||||
listeningFunc_ = -1;
|
||||
status_ = "Bound to " + std::string(kButtonNames[b]);
|
||||
saveConfig();
|
||||
@@ -323,6 +530,24 @@ std::string SettingsMenu::downloadDir() const {
|
||||
return d[0] == '~' ? base + (d + 1) : std::string(d);
|
||||
}
|
||||
|
||||
float SettingsMenu::screenCurvature() const {
|
||||
return kCurvatureValues[curvatureIdx_ % kCurvatureCount];
|
||||
}
|
||||
|
||||
void SettingsMenu::setCurvatureIdx(int idx) {
|
||||
curvatureIdx_ = ((idx % kCurvatureCount) + kCurvatureCount) % kCurvatureCount;
|
||||
saveConfig();
|
||||
refresh();
|
||||
}
|
||||
|
||||
float SettingsMenu::uiTextScale() const {
|
||||
return kUiTextScaleValues[uiTextScaleIdx_ % kUiTextScaleCount];
|
||||
}
|
||||
|
||||
float SettingsMenu::uiDistance() const {
|
||||
return kUiDistanceValues[uiDistanceIdx_ % kUiDistanceCount];
|
||||
}
|
||||
|
||||
void SettingsMenu::loadConfig() {
|
||||
const std::string path = getConfigPath();
|
||||
std::ifstream file(path);
|
||||
@@ -343,9 +568,27 @@ void SettingsMenu::loadConfig() {
|
||||
else if (key == "distance") screenDistance_ = std::clamp(std::stof(val), 1.0f, 10.0f);
|
||||
else if (key == "bind_play_btn") bindPlayBtn_ = std::clamp(std::stoi(val), 0, kButtonCount - 1);
|
||||
else if (key == "bind_menu_btn") bindMenuBtn_ = std::clamp(std::stoi(val), 0, kButtonCount - 1);
|
||||
else if (key == "bind_recenter_btn") bindRecenterBtn_ = std::clamp(std::stoi(val), 0, kButtonCount - 1);
|
||||
else if (key == "bind_recenter_btn") {
|
||||
const int b = std::clamp(std::stoi(val), 0, kButtonCount - 1);
|
||||
// Grip used to default to recenter; it now toggles the laser pointer,
|
||||
// so a saved grip binding is migrated to the new default (Bumper).
|
||||
bindRecenterBtn_ = (b == 8) ? 6 : b;
|
||||
}
|
||||
else if (key == "bind_quick_path_btn") bindQuickPathBtn_ = std::clamp(std::stoi(val), 0, kButtonCount - 1);
|
||||
else if (key == "download_dir") downloadDirIdx_ = std::clamp(std::stoi(val), 0, kDownloadDirCount - 1);
|
||||
else if (key == "audio_lang") audioLangIdx_ = std::clamp(std::stoi(val), 0, kLangCount - 1);
|
||||
else if (key == "curvature") curvatureIdx_ = std::clamp(std::stoi(val), 0, kCurvatureCount - 1);
|
||||
else if (key == "ui_text_scale") uiTextScaleIdx_ = std::clamp(std::stoi(val), 0, kUiTextScaleCount - 1);
|
||||
else if (key == "ui_text_color") uiTextColorIdx_ = std::clamp(std::stoi(val), 0, kUiTextColorCount - 1);
|
||||
else if (key == "ui_accent_color") uiAccentColorIdx_ = std::clamp(std::stoi(val), 0, kUiAccentColorCount - 1);
|
||||
else if (key == "ui_distance") uiDistanceIdx_ = std::clamp(std::stoi(val), 0, kUiDistanceCount - 1);
|
||||
else if (key == "ui_follow_mode") uiFollowMode_ = std::clamp(std::stoi(val), 0, 1);
|
||||
else if (key == "browser_view_mode") browserViewMode_ = std::clamp(std::stoi(val), 0, 2);
|
||||
else if (key == "list_show_format") listShowFormat_ = (val == "1" || val == "true");
|
||||
else if (key == "list_show_res") listShowRes_ = (val == "1" || val == "true");
|
||||
else if (key == "list_show_size") listShowSize_ = (val == "1" || val == "true");
|
||||
else if (key == "list_show_dur") listShowDuration_ = (val == "1" || val == "true");
|
||||
else if (key == "quick_path") quickPath_ = val;
|
||||
}
|
||||
PIXELVR_LOG_INFO("Loaded settings from %s", path.c_str());
|
||||
}
|
||||
@@ -368,8 +611,23 @@ void SettingsMenu::saveConfig() const {
|
||||
file << "bind_play_btn=" << bindPlayBtn_ << "\n";
|
||||
file << "bind_menu_btn=" << bindMenuBtn_ << "\n";
|
||||
file << "bind_recenter_btn=" << bindRecenterBtn_ << "\n";
|
||||
file << "bind_quick_path_btn=" << bindQuickPathBtn_ << "\n";
|
||||
file << "download_dir=" << downloadDirIdx_ << "\n";
|
||||
file << "audio_lang=" << audioLangIdx_ << "\n";
|
||||
file << "curvature=" << curvatureIdx_ << "\n";
|
||||
file << "ui_text_scale=" << uiTextScaleIdx_ << "\n";
|
||||
file << "ui_text_color=" << uiTextColorIdx_ << "\n";
|
||||
file << "ui_accent_color=" << uiAccentColorIdx_ << "\n";
|
||||
file << "ui_distance=" << uiDistanceIdx_ << "\n";
|
||||
file << "ui_follow_mode=" << uiFollowMode_ << "\n";
|
||||
file << "browser_view_mode=" << browserViewMode_ << "\n";
|
||||
file << "list_show_format=" << (listShowFormat_ ? 1 : 0) << "\n";
|
||||
file << "list_show_res=" << (listShowRes_ ? 1 : 0) << "\n";
|
||||
file << "list_show_size=" << (listShowSize_ ? 1 : 0) << "\n";
|
||||
file << "list_show_dur=" << (listShowDuration_ ? 1 : 0) << "\n";
|
||||
if (!quickPath_.empty()) {
|
||||
file << "quick_path=" << quickPath_ << "\n";
|
||||
}
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
PIXELVR_LOG_WARN("Could not save settings: %s", e.what());
|
||||
|
||||
@@ -25,6 +25,14 @@ public:
|
||||
ToggleModels,
|
||||
ToggleStats,
|
||||
CycleDistance,
|
||||
CycleCurvature,
|
||||
CycleUiTextScale,
|
||||
CycleUiTextColor,
|
||||
CycleUiAccentColor,
|
||||
CycleUiDistance,
|
||||
CycleUiFollowMode,
|
||||
CycleBrowserViewMode,
|
||||
ListenQuickPath,
|
||||
EnterVideoList,
|
||||
EnterAudioList,
|
||||
EnterLangList,
|
||||
@@ -32,6 +40,17 @@ public:
|
||||
PickAudioStream,
|
||||
PickLanguage,
|
||||
RecenterScreen,
|
||||
CycleVaultAutoLockHeadset,
|
||||
CycleVaultTimeout,
|
||||
ListenVaultMoveBtn,
|
||||
RecordVaultCombo,
|
||||
LockVaultAction,
|
||||
EnterListColsConfig,
|
||||
ToggleListColFormat,
|
||||
ToggleListColRes,
|
||||
ToggleListColSize,
|
||||
ToggleListColDuration,
|
||||
BackToUI,
|
||||
Close
|
||||
};
|
||||
|
||||
@@ -49,6 +68,7 @@ public:
|
||||
void close() { open_ = false; }
|
||||
bool isOpen() const { return open_; }
|
||||
void setOpen(bool o) { open_ = o; if (o) refresh(); }
|
||||
void refresh();
|
||||
|
||||
const std::string& title() const { return title_; }
|
||||
const std::string& status() const { return status_; }
|
||||
@@ -77,6 +97,27 @@ public:
|
||||
std::string downloadDir() const;
|
||||
float screenDistance() const { return screenDistance_; }
|
||||
void setScreenDistance(float d) { screenDistance_ = d; refresh(); }
|
||||
float screenCurvature() const;
|
||||
void setCurvatureIdx(int idx);
|
||||
int curvatureIdx() const { return curvatureIdx_; }
|
||||
float uiTextScale() const;
|
||||
int uiTextColorIdx() const { return uiTextColorIdx_; }
|
||||
int uiAccentColorIdx() const { return uiAccentColorIdx_; }
|
||||
float uiDistance() const;
|
||||
int uiFollowMode() const { return uiFollowMode_; }
|
||||
void setUiFollowMode(int m) { uiFollowMode_ = m; saveConfig(); refresh(); }
|
||||
int browserViewMode() const { return browserViewMode_; }
|
||||
void setBrowserViewMode(int m) { browserViewMode_ = m; saveConfig(); refresh(); }
|
||||
int bindQuickPath() const { return bindQuickPathBtn_; }
|
||||
const std::string& quickPath() const { return quickPath_; }
|
||||
void setQuickPath(const std::string& p) { quickPath_ = p; saveConfig(); }
|
||||
|
||||
int section() const { return section_; }
|
||||
void setSection(int s) {
|
||||
section_ = s;
|
||||
selected_ = 0;
|
||||
refresh();
|
||||
}
|
||||
|
||||
// Track lists (container index + label) for the current media, pushed by the
|
||||
// app on load. selected*Stream() return the chosen container index (-1 none).
|
||||
@@ -90,9 +131,15 @@ public:
|
||||
void loadConfig();
|
||||
void saveConfig() const;
|
||||
|
||||
private:
|
||||
void refresh();
|
||||
bool listShowFormat() const { return listShowFormat_; }
|
||||
bool listShowRes() const { return listShowRes_; }
|
||||
bool listShowSize() const { return listShowSize_; }
|
||||
bool listShowDuration() const { return listShowDuration_; }
|
||||
|
||||
void onKeyboardSubmit(const std::string& text) { (void)text; }
|
||||
void onKeyboardCancel() {}
|
||||
|
||||
private:
|
||||
bool open_ = false;
|
||||
std::string title_ = "Settings & Controller Remap";
|
||||
std::string status_ = "Point + Trigger: Change Setting | Grip: Recenter | B/Y: Close";
|
||||
@@ -109,11 +156,24 @@ private:
|
||||
// Function -> physical button (XrInputState::Button index); press-to-bind.
|
||||
int bindPlayBtn_ = 0; // default A
|
||||
int bindMenuBtn_ = 1; // default B
|
||||
int bindRecenterBtn_ = 8; // default Grip
|
||||
int listeningFunc_ = -1; // 0=play 1=menu 2=recenter while binding, else -1
|
||||
int bindRecenterBtn_ = 6; // default Bumper (grip toggles the laser)
|
||||
int bindQuickPathBtn_ = 7; // default Right Stick Click (BtnThumbR)
|
||||
int listeningFunc_ = -1; // 0=play 1=menu 2=recenter 3=move 4=quick path
|
||||
int downloadDirIdx_ = 0; // index into the download-destination presets
|
||||
int curvatureIdx_ = 0; // 0=Flat (0%), 1=Gentle (25%), 2=Cinema (50%), 3=Deep (75%), 4=IMAX (100%)
|
||||
int uiTextScaleIdx_ = 2; // 0=90%, 1=105%, 2=120% (default), 3=140%, 4=160%
|
||||
int uiTextColorIdx_ = 0; // 0=Pure White, 1=Ice Cyan, 2=Warm Cream, 3=Soft Gold, 4=Mint Green
|
||||
int uiAccentColorIdx_ = 0; // 0=Cyber Cyan, 1=Emerald, 2=Amber Gold, 3=Royal Purple, 4=Crimson
|
||||
int uiDistanceIdx_ = 1; // 0=Near (1.5m), 1=Comfort (1.9m), 2=Mid (2.3m), 3=Far (2.8m)
|
||||
int uiFollowMode_ = 0; // 0=World Lock (default, zero drift/tilt), 1=Smooth Gaze Follow
|
||||
int browserViewMode_ = 0; // 0=Large Cards (3x2), 1=Smaller Cards (4x3), 2=Compact List (1-Col)
|
||||
bool listShowFormat_ = true;
|
||||
bool listShowRes_ = true;
|
||||
bool listShowSize_ = true;
|
||||
bool listShowDuration_ = true;
|
||||
std::string quickPath_;
|
||||
|
||||
int section_ = -1; // -1 = top-level section list; 0..3 = a section's rows
|
||||
int section_ = 0; // 0=Display, 1=UI, 2=Audio, 3=Controls, 4=Vault; 10..12=sub-lists
|
||||
std::vector<std::pair<int, std::string>> videoStreams_; // (index, label)
|
||||
std::vector<std::pair<int, std::string>> audioStreams_;
|
||||
int videoSel_ = 0; // position within videoStreams_
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
#include "ui/thumbnail_cache.hpp"
|
||||
#include "app/vault_manager.hpp"
|
||||
#include "util/logging.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
|
||||
#ifdef PIXELVR_HAVE_MEDIA
|
||||
extern "C" {
|
||||
#include <libavcodec/avcodec.h>
|
||||
#include <libavformat/avformat.h>
|
||||
#include <libavutil/imgutils.h>
|
||||
#include <libswscale/swscale.h>
|
||||
}
|
||||
#endif
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
namespace pixelvr {
|
||||
|
||||
constexpr int kThumbW = 256;
|
||||
constexpr int kThumbH = 144;
|
||||
constexpr std::size_t kThumbBytes = kThumbW * kThumbH * 4;
|
||||
|
||||
struct DiskMetaHeader {
|
||||
uint32_t magic = 0x5056524D; // 'PVRM'
|
||||
int32_t width = 0;
|
||||
int32_t height = 0;
|
||||
double fps = 0.0;
|
||||
double durationSec = 0.0;
|
||||
};
|
||||
|
||||
static uint64_t fnv1a64(const std::string& str) {
|
||||
uint64_t hash = 14695981039346656037ULL;
|
||||
for (char c : str) {
|
||||
hash ^= static_cast<uint8_t>(c);
|
||||
hash *= 1099511628211ULL;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
ThumbnailCache::ThumbnailCache() {
|
||||
const char* home = std::getenv("HOME");
|
||||
std::string base = (home != nullptr) ? home : "/home/steamos";
|
||||
cacheDir_ = base + "/.cache/pixelvr/thumbs";
|
||||
std::error_code ec;
|
||||
fs::create_directories(cacheDir_, ec);
|
||||
|
||||
worker_ = std::thread([this] { workerLoop(); });
|
||||
}
|
||||
|
||||
ThumbnailCache::~ThumbnailCache() {
|
||||
stop_.store(true);
|
||||
cv_.notify_all();
|
||||
if (worker_.joinable()) {
|
||||
worker_.join();
|
||||
}
|
||||
}
|
||||
|
||||
void ThumbnailCache::request(const std::string& videoPath) {
|
||||
if (videoPath.empty() || videoPath[0] != '/') return;
|
||||
std::lock_guard<std::mutex> lk(mutex_);
|
||||
if (queuedPaths_.find(videoPath) != queuedPaths_.end() ||
|
||||
cache_.find(videoPath) != cache_.end()) {
|
||||
return;
|
||||
}
|
||||
queuedPaths_[videoPath] = true;
|
||||
queue_.push(videoPath);
|
||||
cv_.notify_one();
|
||||
}
|
||||
|
||||
bool ThumbnailCache::get(const std::string& videoPath, std::vector<uint8_t>& outRgba) {
|
||||
if (videoPath.empty()) return false;
|
||||
std::lock_guard<std::mutex> lk(mutex_);
|
||||
auto it = cache_.find(videoPath);
|
||||
if (it != cache_.end() && it->second.size() == kThumbBytes) {
|
||||
outRgba = it->second;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool ThumbnailCache::getMeta(const std::string& videoPath, MediaMeta& outMeta) {
|
||||
if (videoPath.empty()) return false;
|
||||
std::lock_guard<std::mutex> lk(mutex_);
|
||||
auto it = metaCache_.find(videoPath);
|
||||
if (it != metaCache_.end() && it->second.ready) {
|
||||
outMeta = it->second;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void ThumbnailCache::clearVaultThumbs(const std::string& vaultDir) {
|
||||
if (vaultDir.empty()) return;
|
||||
std::lock_guard<std::mutex> lk(mutex_);
|
||||
for (auto it = cache_.begin(); it != cache_.end(); ) {
|
||||
if (it->first.rfind(vaultDir, 0) == 0) {
|
||||
it = cache_.erase(it);
|
||||
} else {
|
||||
++it;
|
||||
}
|
||||
}
|
||||
for (auto it = metaCache_.begin(); it != metaCache_.end(); ) {
|
||||
if (it->first.rfind(vaultDir, 0) == 0) {
|
||||
it = metaCache_.erase(it);
|
||||
} else {
|
||||
++it;
|
||||
}
|
||||
}
|
||||
for (auto it = queuedPaths_.begin(); it != queuedPaths_.end(); ) {
|
||||
if (it->first.rfind(vaultDir, 0) == 0) {
|
||||
it = queuedPaths_.erase(it);
|
||||
} else {
|
||||
++it;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool ThumbnailCache::extractThumbnailAndMeta(const std::string& videoPath,
|
||||
std::vector<uint8_t>& outRgba,
|
||||
MediaMeta& outMeta) {
|
||||
#ifdef PIXELVR_HAVE_MEDIA
|
||||
AVFormatContext* fmt = nullptr;
|
||||
if (avformat_open_input(&fmt, videoPath.c_str(), nullptr, nullptr) < 0 || !fmt) {
|
||||
return false;
|
||||
}
|
||||
if (avformat_find_stream_info(fmt, nullptr) < 0) {
|
||||
avformat_close_input(&fmt);
|
||||
return false;
|
||||
}
|
||||
|
||||
int vIdx = av_find_best_stream(fmt, AVMEDIA_TYPE_VIDEO, -1, -1, nullptr, 0);
|
||||
if (vIdx < 0) {
|
||||
avformat_close_input(&fmt);
|
||||
return false;
|
||||
}
|
||||
|
||||
AVStream* st = fmt->streams[vIdx];
|
||||
outMeta.width = st->codecpar->width;
|
||||
outMeta.height = st->codecpar->height;
|
||||
if (st->avg_frame_rate.den > 0 && st->avg_frame_rate.num > 0) {
|
||||
outMeta.fps = av_q2d(st->avg_frame_rate);
|
||||
} else if (st->r_frame_rate.den > 0 && st->r_frame_rate.num > 0) {
|
||||
outMeta.fps = av_q2d(st->r_frame_rate);
|
||||
}
|
||||
if (fmt->duration > 0) {
|
||||
outMeta.durationSec = static_cast<double>(fmt->duration) / AV_TIME_BASE;
|
||||
} else if (st->duration > 0 && st->time_base.den > 0) {
|
||||
outMeta.durationSec = static_cast<double>(st->duration) * av_q2d(st->time_base);
|
||||
}
|
||||
outMeta.ready = true;
|
||||
|
||||
// Find standard software decoder for thumbnail extraction so we never touch V4L2/DRM hardware slots
|
||||
const AVCodec* dec = avcodec_find_decoder(st->codecpar->codec_id);
|
||||
if (dec && dec->name && std::strstr(dec->name, "v4l2") != nullptr) {
|
||||
if (st->codecpar->codec_id == AV_CODEC_ID_HEVC) {
|
||||
if (const AVCodec* sw = avcodec_find_decoder_by_name("hevc")) dec = sw;
|
||||
} else if (st->codecpar->codec_id == AV_CODEC_ID_H264) {
|
||||
if (const AVCodec* sw = avcodec_find_decoder_by_name("h264")) dec = sw;
|
||||
}
|
||||
}
|
||||
if (!dec) {
|
||||
avformat_close_input(&fmt);
|
||||
return false;
|
||||
}
|
||||
|
||||
AVCodecContext* ctx = avcodec_alloc_context3(dec);
|
||||
if (!ctx) {
|
||||
avformat_close_input(&fmt);
|
||||
return false;
|
||||
}
|
||||
avcodec_parameters_to_context(ctx, st->codecpar);
|
||||
ctx->thread_count = 2;
|
||||
ctx->skip_loop_filter = AVDISCARD_ALL;
|
||||
if (avcodec_open2(ctx, dec, nullptr) < 0) {
|
||||
avcodec_free_context(&ctx);
|
||||
avformat_close_input(&fmt);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Seek ~10% into the clip (clamped between 3s and 30s) to avoid black intros
|
||||
if (outMeta.durationSec > 6.0) {
|
||||
double targetSec = std::clamp(outMeta.durationSec * 0.10, 3.0, 30.0);
|
||||
int64_t ts = static_cast<int64_t>(targetSec * AV_TIME_BASE);
|
||||
av_seek_frame(fmt, -1, ts, AVSEEK_FLAG_BACKWARD);
|
||||
avcodec_flush_buffers(ctx);
|
||||
}
|
||||
|
||||
AVPacket* pkt = av_packet_alloc();
|
||||
AVFrame* frame = av_frame_alloc();
|
||||
bool gotFrame = false;
|
||||
|
||||
auto decodeAttempt = [&]() {
|
||||
int packetsRead = 0;
|
||||
while (packetsRead < 300 && av_read_frame(fmt, pkt) >= 0) {
|
||||
if (pkt->stream_index == vIdx) {
|
||||
++packetsRead;
|
||||
if (avcodec_send_packet(ctx, pkt) == 0) {
|
||||
if (avcodec_receive_frame(ctx, frame) == 0) {
|
||||
gotFrame = true;
|
||||
av_packet_unref(pkt);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
av_packet_unref(pkt);
|
||||
}
|
||||
};
|
||||
|
||||
decodeAttempt();
|
||||
if (!gotFrame && outMeta.durationSec > 6.0) {
|
||||
// Fallback to start of file if seek landed near EOF
|
||||
av_seek_frame(fmt, -1, 0, AVSEEK_FLAG_BACKWARD);
|
||||
avcodec_flush_buffers(ctx);
|
||||
decodeAttempt();
|
||||
}
|
||||
|
||||
bool ok = false;
|
||||
if (gotFrame && frame->width > 0 && frame->height > 0) {
|
||||
// For Side-by-Side (SBS) 3D or VR180 videos (aspect >= 1.95 or width == 2*height),
|
||||
// crop the left eye (left half of frame) so the thumbnail shows a normal single view
|
||||
// instead of a squished double-eye image!
|
||||
int srcW = frame->width;
|
||||
int srcH = frame->height;
|
||||
const float ar = static_cast<float>(srcW) / static_cast<float>(srcH);
|
||||
std::string lowerPath = videoPath;
|
||||
for (char& c : lowerPath) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
||||
const bool isSbs = (lowerPath.find("sbs") != std::string::npos ||
|
||||
lowerPath.find("vr180") != std::string::npos ||
|
||||
lowerPath.find("180") != std::string::npos ||
|
||||
ar >= 2.6f);
|
||||
if (isSbs && srcW >= 2 * kThumbW) {
|
||||
srcW = frame->width / 2;
|
||||
}
|
||||
|
||||
SwsContext* sws = sws_getContext(
|
||||
srcW, srcH, static_cast<AVPixelFormat>(frame->format),
|
||||
kThumbW, kThumbH, AV_PIX_FMT_RGBA,
|
||||
SWS_BILINEAR, nullptr, nullptr, nullptr);
|
||||
if (sws) {
|
||||
outRgba.resize(kThumbBytes);
|
||||
uint8_t* dstData[4] = {outRgba.data(), nullptr, nullptr, nullptr};
|
||||
int dstLinesize[4] = {kThumbW * 4, 0, 0, 0};
|
||||
sws_scale(sws, frame->data, frame->linesize, 0, srcH, dstData, dstLinesize);
|
||||
sws_freeContext(sws);
|
||||
ok = true;
|
||||
}
|
||||
}
|
||||
|
||||
av_frame_free(&frame);
|
||||
av_packet_free(&pkt);
|
||||
avcodec_free_context(&ctx);
|
||||
avformat_close_input(&fmt);
|
||||
return ok;
|
||||
#else
|
||||
(void)videoPath;
|
||||
(void)outRgba;
|
||||
(void)outMeta;
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
void ThumbnailCache::workerLoop() {
|
||||
while (!stop_.load()) {
|
||||
std::string path;
|
||||
{
|
||||
std::unique_lock<std::mutex> lk(mutex_);
|
||||
cv_.wait(lk, [this] { return stop_.load() || !queue_.empty(); });
|
||||
if (stop_.load()) break;
|
||||
path = queue_.front();
|
||||
queue_.pop();
|
||||
}
|
||||
|
||||
if (path.empty() || path[0] != '/') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const bool inVault = VaultManager::instance().isVaultPath(path);
|
||||
const std::string targetCacheDir = inVault ? VaultManager::instance().thumbsDir() : cacheDir_;
|
||||
const std::string baseHash = std::to_string(fnv1a64(path));
|
||||
const std::string cacheFile = targetCacheDir + "/" + baseHash + ".rgba";
|
||||
const std::string metaFile = targetCacheDir + "/" + baseHash + ".meta";
|
||||
|
||||
std::error_code ec;
|
||||
if (fs::is_regular_file(cacheFile, ec) && fs::file_size(cacheFile, ec) == kThumbBytes) {
|
||||
std::ifstream in(cacheFile, std::ios::binary);
|
||||
std::vector<uint8_t> data(kThumbBytes);
|
||||
if (in.read(reinterpret_cast<char*>(data.data()), kThumbBytes)) {
|
||||
MediaMeta meta{};
|
||||
if (fs::is_regular_file(metaFile, ec) &&
|
||||
fs::file_size(metaFile, ec) == sizeof(DiskMetaHeader)) {
|
||||
std::ifstream min(metaFile, std::ios::binary);
|
||||
DiskMetaHeader hdr{};
|
||||
if (min.read(reinterpret_cast<char*>(&hdr), sizeof(hdr)) &&
|
||||
hdr.magic == 0x5056524D) {
|
||||
meta.width = hdr.width;
|
||||
meta.height = hdr.height;
|
||||
meta.fps = hdr.fps;
|
||||
meta.durationSec = hdr.durationSec;
|
||||
meta.ready = true;
|
||||
}
|
||||
}
|
||||
if (!meta.ready) {
|
||||
// Quick metadata probe if .meta was not cached yet
|
||||
std::vector<uint8_t> dummy;
|
||||
extractThumbnailAndMeta(path, dummy, meta);
|
||||
if (meta.ready) {
|
||||
DiskMetaHeader hdr{0x5056524D, meta.width, meta.height, meta.fps, meta.durationSec};
|
||||
std::ofstream mout(metaFile, std::ios::binary);
|
||||
mout.write(reinterpret_cast<const char*>(&hdr), sizeof(hdr));
|
||||
}
|
||||
}
|
||||
std::lock_guard<std::mutex> lk(mutex_);
|
||||
cache_[path] = std::move(data);
|
||||
if (meta.ready) metaCache_[path] = meta;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<uint8_t> rgba;
|
||||
MediaMeta meta{};
|
||||
const bool ok = extractThumbnailAndMeta(path, rgba, meta);
|
||||
if (meta.ready) {
|
||||
DiskMetaHeader hdr{0x5056524D, meta.width, meta.height, meta.fps, meta.durationSec};
|
||||
std::ofstream mout(metaFile, std::ios::binary);
|
||||
mout.write(reinterpret_cast<const char*>(&hdr), sizeof(hdr));
|
||||
std::lock_guard<std::mutex> lk(mutex_);
|
||||
metaCache_[path] = meta;
|
||||
}
|
||||
if (ok && rgba.size() == kThumbBytes) {
|
||||
{
|
||||
std::ofstream out(cacheFile, std::ios::binary);
|
||||
out.write(reinterpret_cast<const char*>(rgba.data()), kThumbBytes);
|
||||
}
|
||||
std::lock_guard<std::mutex> lk(mutex_);
|
||||
cache_[path] = std::move(rgba);
|
||||
PIXELVR_LOG_INFO("Thumbnail generated for %s (%dx%d, %.0fs)",
|
||||
path.c_str(), meta.width, meta.height, meta.durationSec);
|
||||
} else {
|
||||
PIXELVR_LOG_WARN("Thumbnail generation failed for %s", path.c_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace pixelvr
|
||||
@@ -0,0 +1,57 @@
|
||||
#pragma once
|
||||
|
||||
#include <atomic>
|
||||
#include <condition_variable>
|
||||
#include <cstdint>
|
||||
#include <mutex>
|
||||
#include <queue>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
namespace pixelvr {
|
||||
|
||||
class ThumbnailCache {
|
||||
public:
|
||||
struct MediaMeta {
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
double fps = 0.0;
|
||||
double durationSec = 0.0;
|
||||
bool ready = false;
|
||||
};
|
||||
|
||||
ThumbnailCache();
|
||||
~ThumbnailCache();
|
||||
|
||||
// Requests thumbnail extraction/load and metadata probe for a path (non-blocking).
|
||||
void request(const std::string& videoPath);
|
||||
|
||||
// Returns true and copies out the 256x144 RGBA pixels if ready.
|
||||
bool get(const std::string& videoPath, std::vector<uint8_t>& outRgba);
|
||||
|
||||
// Returns true and populates MediaMeta if probed.
|
||||
bool getMeta(const std::string& videoPath, MediaMeta& outMeta);
|
||||
|
||||
// Clears cached thumbnails and metadata whose paths start with vaultDir.
|
||||
void clearVaultThumbs(const std::string& vaultDir);
|
||||
|
||||
private:
|
||||
void workerLoop();
|
||||
bool extractThumbnailAndMeta(const std::string& videoPath,
|
||||
std::vector<uint8_t>& outRgba,
|
||||
MediaMeta& outMeta);
|
||||
|
||||
std::string cacheDir_;
|
||||
std::mutex mutex_;
|
||||
std::condition_variable cv_;
|
||||
std::queue<std::string> queue_;
|
||||
std::unordered_map<std::string, bool> queuedPaths_;
|
||||
std::unordered_map<std::string, std::vector<uint8_t>> cache_;
|
||||
std::unordered_map<std::string, MediaMeta> metaCache_;
|
||||
std::atomic<bool> stop_{false};
|
||||
std::thread worker_;
|
||||
};
|
||||
|
||||
} // namespace pixelvr
|
||||
@@ -0,0 +1,107 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <sstream>
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
|
||||
namespace pixelvr {
|
||||
|
||||
// Validates whether a string is a complete, strictly valid IPv4 address (e.g. "192.168.1.1").
|
||||
inline bool isValidIpv4(const std::string& str) {
|
||||
if (str.empty()) return false;
|
||||
std::stringstream ss(str);
|
||||
std::string seg;
|
||||
int count = 0;
|
||||
while (std::getline(ss, seg, '.')) {
|
||||
++count;
|
||||
if (seg.empty() || seg.length() > 3) return false;
|
||||
for (char c : seg) {
|
||||
if (!std::isdigit(static_cast<unsigned char>(c))) return false;
|
||||
}
|
||||
// Leading zeros not allowed if more than 1 digit (e.g. "01" is invalid, "0" is valid)
|
||||
if (seg.length() > 1 && seg[0] == '0') return false;
|
||||
try {
|
||||
int val = std::stoi(seg);
|
||||
if (val < 0 || val > 255) return false;
|
||||
} catch (...) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Must have exactly 4 octets and not end with a trailing dot
|
||||
return (count == 4 && str.back() != '.');
|
||||
}
|
||||
|
||||
// Sanitizes and formats an in-progress or finalized IPv4 input:
|
||||
// - Only digits '0'..'9' and '.' are accepted; any other characters are discarded.
|
||||
// - At most 4 sections (octets).
|
||||
// - Each section has leading zeros dropped (e.g. "001" -> "1", "050" -> "50", "00" -> "0").
|
||||
// - Each section is clamped to max 255.
|
||||
// - If !finalized, a trailing dot is preserved while typing (e.g. "192.168.").
|
||||
// - If finalized, any trailing dot is trimmed and empty trailing sections are removed.
|
||||
inline std::string sanitizeIpv4(const std::string& input, bool finalized = false) {
|
||||
if (input.empty()) return "";
|
||||
|
||||
std::vector<std::string> sections;
|
||||
std::string current;
|
||||
bool trailingDot = false;
|
||||
|
||||
for (char c : input) {
|
||||
if (c == '.') {
|
||||
if (!current.empty() && sections.size() < 4) {
|
||||
sections.push_back(current);
|
||||
current.clear();
|
||||
trailingDot = true;
|
||||
}
|
||||
} else if (std::isdigit(static_cast<unsigned char>(c))) {
|
||||
if (sections.size() >= 4) {
|
||||
continue;
|
||||
}
|
||||
if (current.length() < 3) {
|
||||
current += c;
|
||||
trailingDot = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!current.empty() && sections.size() < 4) {
|
||||
sections.push_back(current);
|
||||
trailingDot = false;
|
||||
}
|
||||
|
||||
if (sections.empty() && !trailingDot) return "";
|
||||
|
||||
std::string result;
|
||||
for (std::size_t i = 0; i < sections.size(); ++i) {
|
||||
std::string s = sections[i];
|
||||
if (!s.empty()) {
|
||||
while (s.length() > 1 && s[0] == '0') {
|
||||
s.erase(s.begin());
|
||||
}
|
||||
try {
|
||||
int val = std::stoi(s);
|
||||
if (val > 255) val = 255;
|
||||
s = std::to_string(val);
|
||||
} catch (...) {
|
||||
s = "255";
|
||||
}
|
||||
}
|
||||
|
||||
if (i > 0) result += '.';
|
||||
result += s;
|
||||
}
|
||||
|
||||
if (!finalized && trailingDot && sections.size() < 4 && !result.empty()) {
|
||||
result += '.';
|
||||
}
|
||||
|
||||
if (finalized) {
|
||||
while (!result.empty() && result.back() == '.') {
|
||||
result.pop_back();
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace pixelvr
|
||||
@@ -29,6 +29,10 @@ bool RenderModelSystem::init(XrInstance instance, XrSession session, bool enable
|
||||
load("xrGetRenderModelStateEXT", pfnGetState_) &&
|
||||
load("xrDestroyRenderModelEXT", pfnDestroyModel_) &&
|
||||
load("xrDestroyRenderModelAssetEXT", pfnDestroyAsset_);
|
||||
|
||||
// Optional helper to map models to hands directly:
|
||||
load("xrEnumerateRenderModelSubactionPathsEXT", pfnEnumerateSubactionPaths_);
|
||||
|
||||
if (!available_) {
|
||||
PIXELVR_LOG_WARN("XR_EXT_render_model entry points unavailable");
|
||||
}
|
||||
@@ -37,13 +41,13 @@ bool RenderModelSystem::init(XrInstance instance, XrSession session, bool enable
|
||||
|
||||
bool RenderModelSystem::poll() {
|
||||
if (!available_ || done_) {
|
||||
return assetReady();
|
||||
return assetReady(0) || assetReady(1);
|
||||
}
|
||||
fetchFirstModel();
|
||||
return assetReady();
|
||||
fetchModels();
|
||||
return assetReady(0) || assetReady(1);
|
||||
}
|
||||
|
||||
bool RenderModelSystem::fetchFirstModel() {
|
||||
bool RenderModelSystem::fetchModels() {
|
||||
XrInteractionRenderModelIdsEnumerateInfoEXT enumInfo{
|
||||
XR_TYPE_INTERACTION_RENDER_MODEL_IDS_ENUMERATE_INFO_EXT};
|
||||
uint32_t idCount = 0;
|
||||
@@ -57,104 +61,153 @@ bool RenderModelSystem::fetchFirstModel() {
|
||||
return false;
|
||||
}
|
||||
|
||||
XrRenderModelIdEXT id = XR_NULL_RENDER_MODEL_ID_EXT;
|
||||
for (auto v : ids) {
|
||||
if (v != XR_NULL_RENDER_MODEL_ID_EXT) {
|
||||
id = v;
|
||||
break;
|
||||
PIXELVR_LOG_INFO("OpenXR enumerated %u render model IDs", idCount);
|
||||
|
||||
for (uint32_t i = 0; i < idCount; ++i) {
|
||||
XrRenderModelIdEXT id = ids[i];
|
||||
if (id == XR_NULL_RENDER_MODEL_ID_EXT) continue;
|
||||
|
||||
if ((models_[0].ready && models_[0].id == id) ||
|
||||
(models_[1].ready && models_[1].id == id)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (id == XR_NULL_RENDER_MODEL_ID_EXT) {
|
||||
return false;
|
||||
}
|
||||
done_ = true; // from here on, success or hard failure — do not retry
|
||||
|
||||
XrRenderModelCreateInfoEXT mci{XR_TYPE_RENDER_MODEL_CREATE_INFO_EXT};
|
||||
mci.renderModelId = id;
|
||||
if (XR_FAILED(pfnCreateModel_(session_, &mci, &model_))) {
|
||||
PIXELVR_LOG_WARN("xrCreateRenderModelEXT failed");
|
||||
return false;
|
||||
}
|
||||
XrRenderModelCreateInfoEXT mci{XR_TYPE_RENDER_MODEL_CREATE_INFO_EXT};
|
||||
mci.renderModelId = id;
|
||||
XrRenderModelEXT model = XR_NULL_HANDLE;
|
||||
if (XR_FAILED(pfnCreateModel_(session_, &mci, &model))) {
|
||||
PIXELVR_LOG_WARN("xrCreateRenderModelEXT failed for id %llu",
|
||||
static_cast<unsigned long long>(id));
|
||||
continue;
|
||||
}
|
||||
|
||||
XrRenderModelPropertiesGetInfoEXT pgi{
|
||||
XR_TYPE_RENDER_MODEL_PROPERTIES_GET_INFO_EXT};
|
||||
XrRenderModelPropertiesEXT props{XR_TYPE_RENDER_MODEL_PROPERTIES_EXT};
|
||||
if (XR_FAILED(pfnGetProps_(model_, &pgi, &props))) {
|
||||
PIXELVR_LOG_WARN("xrGetRenderModelPropertiesEXT failed");
|
||||
return false;
|
||||
}
|
||||
animatableNodeCount_ = props.animatableNodeCount;
|
||||
|
||||
XrRenderModelAssetCreateInfoEXT aci{XR_TYPE_RENDER_MODEL_ASSET_CREATE_INFO_EXT};
|
||||
aci.cacheId = props.cacheId;
|
||||
if (XR_FAILED(pfnCreateAsset_(session_, &aci, &asset_))) {
|
||||
PIXELVR_LOG_WARN("xrCreateRenderModelAssetEXT failed");
|
||||
return false;
|
||||
}
|
||||
|
||||
XrRenderModelAssetDataGetInfoEXT adi{
|
||||
XR_TYPE_RENDER_MODEL_ASSET_DATA_GET_INFO_EXT};
|
||||
XrRenderModelAssetDataEXT data{XR_TYPE_RENDER_MODEL_ASSET_DATA_EXT};
|
||||
if (XR_FAILED(pfnGetAssetData_(asset_, &adi, &data))) { // size query
|
||||
PIXELVR_LOG_WARN("xrGetRenderModelAssetDataEXT (size) failed");
|
||||
return false;
|
||||
}
|
||||
gltf_.resize(data.bufferCountOutput);
|
||||
data.bufferCapacityInput = static_cast<uint32_t>(gltf_.size());
|
||||
data.buffer = gltf_.data();
|
||||
if (XR_FAILED(pfnGetAssetData_(asset_, &adi, &data))) {
|
||||
PIXELVR_LOG_WARN("xrGetRenderModelAssetDataEXT (data) failed");
|
||||
gltf_.clear();
|
||||
return false;
|
||||
}
|
||||
|
||||
XrRenderModelSpaceCreateInfoEXT sci{XR_TYPE_RENDER_MODEL_SPACE_CREATE_INFO_EXT};
|
||||
sci.renderModel = model_;
|
||||
pfnCreateSpace_(session_, &sci, &modelSpace_); // used by later stages
|
||||
|
||||
// Animatable node names, in the same order as xrGetRenderModelStateEXT node
|
||||
// states, so an animated node can be mapped back to glTF geometry.
|
||||
if (pfnGetAssetProps_ != nullptr) {
|
||||
XrRenderModelAssetPropertiesGetInfoEXT agi{
|
||||
XR_TYPE_RENDER_MODEL_ASSET_PROPERTIES_GET_INFO_EXT};
|
||||
XrRenderModelAssetPropertiesEXT props{
|
||||
XR_TYPE_RENDER_MODEL_ASSET_PROPERTIES_EXT};
|
||||
if (XR_FAILED(pfnGetAssetProps_(asset_, &agi, &props))) {
|
||||
PIXELVR_LOG_WARN("xrGetRenderModelAssetPropertiesEXT (size) failed");
|
||||
} else {
|
||||
std::vector<XrRenderModelAssetNodePropertiesEXT> names(
|
||||
props.nodePropertyCount);
|
||||
props.nodePropertyCount = static_cast<uint32_t>(names.size());
|
||||
props.nodeProperties = names.data();
|
||||
if (XR_SUCCEEDED(pfnGetAssetProps_(asset_, &agi, &props))) {
|
||||
nodeNames_.reserve(names.size());
|
||||
for (const auto& np : names) nodeNames_.emplace_back(np.uniqueName);
|
||||
std::string joined;
|
||||
for (const auto& n : nodeNames_) {
|
||||
joined += n;
|
||||
joined += ' ';
|
||||
int hand = -1;
|
||||
if (pfnEnumerateSubactionPaths_ != nullptr) {
|
||||
XrInteractionRenderModelSubactionPathInfoEXT spi{
|
||||
XR_TYPE_INTERACTION_RENDER_MODEL_SUBACTION_PATH_INFO_EXT};
|
||||
uint32_t pathCount = 0;
|
||||
if (XR_SUCCEEDED(pfnEnumerateSubactionPaths_(model, &spi, 0, &pathCount, nullptr)) && pathCount > 0) {
|
||||
std::vector<XrPath> paths(pathCount);
|
||||
if (XR_SUCCEEDED(pfnEnumerateSubactionPaths_(model, &spi, pathCount, &pathCount, paths.data()))) {
|
||||
for (XrPath p : paths) {
|
||||
char pathBuf[XR_MAX_PATH_LENGTH];
|
||||
uint32_t len = 0;
|
||||
if (XR_SUCCEEDED(xrPathToString(instance_, p, sizeof(pathBuf), &len, pathBuf))) {
|
||||
std::string ps = pathBuf;
|
||||
if (ps.find("left") != std::string::npos) hand = 0;
|
||||
else if (ps.find("right") != std::string::npos) hand = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
PIXELVR_LOG_INFO("Render model nodes: %s", joined.c_str());
|
||||
} else {
|
||||
PIXELVR_LOG_WARN("xrGetRenderModelAssetPropertiesEXT (data) failed");
|
||||
}
|
||||
}
|
||||
|
||||
XrRenderModelPropertiesGetInfoEXT pgi{
|
||||
XR_TYPE_RENDER_MODEL_PROPERTIES_GET_INFO_EXT};
|
||||
XrRenderModelPropertiesEXT props{XR_TYPE_RENDER_MODEL_PROPERTIES_EXT};
|
||||
if (XR_FAILED(pfnGetProps_(model, &pgi, &props))) {
|
||||
PIXELVR_LOG_WARN("xrGetRenderModelPropertiesEXT failed");
|
||||
pfnDestroyModel_(model);
|
||||
continue;
|
||||
}
|
||||
|
||||
XrRenderModelAssetCreateInfoEXT aci{XR_TYPE_RENDER_MODEL_ASSET_CREATE_INFO_EXT};
|
||||
aci.cacheId = props.cacheId;
|
||||
XrRenderModelAssetEXT asset = XR_NULL_HANDLE;
|
||||
if (XR_FAILED(pfnCreateAsset_(session_, &aci, &asset))) {
|
||||
PIXELVR_LOG_WARN("xrCreateRenderModelAssetEXT failed");
|
||||
pfnDestroyModel_(model);
|
||||
continue;
|
||||
}
|
||||
|
||||
XrRenderModelAssetDataGetInfoEXT adi{
|
||||
XR_TYPE_RENDER_MODEL_ASSET_DATA_GET_INFO_EXT};
|
||||
XrRenderModelAssetDataEXT data{XR_TYPE_RENDER_MODEL_ASSET_DATA_EXT};
|
||||
if (XR_FAILED(pfnGetAssetData_(asset, &adi, &data))) { // size query
|
||||
PIXELVR_LOG_WARN("xrGetRenderModelAssetDataEXT (size) failed");
|
||||
pfnDestroyAsset_(asset);
|
||||
pfnDestroyModel_(model);
|
||||
continue;
|
||||
}
|
||||
std::vector<uint8_t> gltf(data.bufferCountOutput);
|
||||
data.bufferCapacityInput = static_cast<uint32_t>(gltf.size());
|
||||
data.buffer = gltf.data();
|
||||
if (XR_FAILED(pfnGetAssetData_(asset, &adi, &data))) {
|
||||
PIXELVR_LOG_WARN("xrGetRenderModelAssetDataEXT (data) failed");
|
||||
pfnDestroyAsset_(asset);
|
||||
pfnDestroyModel_(model);
|
||||
continue;
|
||||
}
|
||||
|
||||
// If hand is still undetermined, inspect glTF JSON header
|
||||
if (hand < 0 && gltf.size() > 50) {
|
||||
std::string gltfText(reinterpret_cast<const char*>(gltf.data()),
|
||||
std::min<std::size_t>(gltf.size(), 4096));
|
||||
if (gltfText.find("left") != std::string::npos ||
|
||||
gltfText.find("Left") != std::string::npos) {
|
||||
hand = 0;
|
||||
} else if (gltfText.find("right") != std::string::npos ||
|
||||
gltfText.find("Right") != std::string::npos) {
|
||||
hand = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: assign to the first unfilled hand slot
|
||||
if (hand < 0) {
|
||||
hand = !models_[0].ready ? 0 : 1;
|
||||
}
|
||||
|
||||
XrRenderModelSpaceCreateInfoEXT sci{XR_TYPE_RENDER_MODEL_SPACE_CREATE_INFO_EXT};
|
||||
sci.renderModel = model;
|
||||
XrSpace modelSpace = XR_NULL_HANDLE;
|
||||
pfnCreateSpace_(session_, &sci, &modelSpace);
|
||||
|
||||
std::vector<std::string> nodeNames;
|
||||
if (pfnGetAssetProps_ != nullptr) {
|
||||
XrRenderModelAssetPropertiesGetInfoEXT agi{
|
||||
XR_TYPE_RENDER_MODEL_ASSET_PROPERTIES_GET_INFO_EXT};
|
||||
XrRenderModelAssetPropertiesEXT aprops{
|
||||
XR_TYPE_RENDER_MODEL_ASSET_PROPERTIES_EXT};
|
||||
if (XR_SUCCEEDED(pfnGetAssetProps_(asset, &agi, &aprops))) {
|
||||
std::vector<XrRenderModelAssetNodePropertiesEXT> names(
|
||||
aprops.nodePropertyCount);
|
||||
aprops.nodePropertyCount = static_cast<uint32_t>(names.size());
|
||||
aprops.nodeProperties = names.data();
|
||||
if (XR_SUCCEEDED(pfnGetAssetProps_(asset, &agi, &aprops))) {
|
||||
nodeNames.reserve(names.size());
|
||||
for (const auto& np : names) nodeNames.emplace_back(np.uniqueName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ModelInfo& mi = models_[hand];
|
||||
mi.id = id;
|
||||
mi.model = model;
|
||||
mi.asset = asset;
|
||||
mi.space = modelSpace;
|
||||
mi.gltf = std::move(gltf);
|
||||
mi.animatableNodeCount = props.animatableNodeCount;
|
||||
mi.nodeNames = std::move(nodeNames);
|
||||
mi.ready = true;
|
||||
|
||||
PIXELVR_LOG_INFO("Render model for %s hand fetched: %u nodes, glTF %zu bytes",
|
||||
hand == 0 ? "left" : "right",
|
||||
mi.animatableNodeCount, mi.gltf.size());
|
||||
}
|
||||
|
||||
PIXELVR_LOG_INFO("Render model fetched: %u animatable nodes, glTF %zu bytes",
|
||||
animatableNodeCount_, gltf_.size());
|
||||
if (FILE* f = std::fopen("/tmp/controller_model.glb", "wb")) {
|
||||
std::fwrite(gltf_.data(), 1, gltf_.size(), f);
|
||||
std::fclose(f);
|
||||
if (models_[0].ready && models_[1].ready) {
|
||||
done_ = true;
|
||||
}
|
||||
return true;
|
||||
return models_[0].ready || models_[1].ready;
|
||||
}
|
||||
|
||||
bool RenderModelSystem::locate(XrSpace base, XrTime time, float outPos[3],
|
||||
bool RenderModelSystem::locate(int hand, XrSpace base, XrTime time, float outPos[3],
|
||||
float outQuat[4]) const {
|
||||
if (modelSpace_ == XR_NULL_HANDLE || time == 0) return false;
|
||||
if (hand < 0 || hand >= 2) return false;
|
||||
const XrSpace space = models_[hand].space;
|
||||
if (space == XR_NULL_HANDLE || time == 0) return false;
|
||||
XrSpaceLocation loc{XR_TYPE_SPACE_LOCATION};
|
||||
if (XR_FAILED(xrLocateSpace(modelSpace_, base, time, &loc))) return false;
|
||||
if (XR_FAILED(xrLocateSpace(space, base, time, &loc))) return false;
|
||||
constexpr XrSpaceLocationFlags need =
|
||||
XR_SPACE_LOCATION_ORIENTATION_VALID_BIT |
|
||||
XR_SPACE_LOCATION_POSITION_VALID_BIT;
|
||||
@@ -169,33 +222,35 @@ bool RenderModelSystem::locate(XrSpace base, XrTime time, float outPos[3],
|
||||
return true;
|
||||
}
|
||||
|
||||
bool RenderModelSystem::nodeStates(XrTime time, std::vector<NodeState>& out) {
|
||||
if (!available_ || model_ == XR_NULL_HANDLE || pfnGetState_ == nullptr ||
|
||||
bool RenderModelSystem::nodeStates(int hand, XrTime time, std::vector<NodeState>& out) {
|
||||
if (hand < 0 || hand >= 2) return false;
|
||||
ModelInfo& mi = models_[hand];
|
||||
if (!available_ || mi.model == XR_NULL_HANDLE || pfnGetState_ == nullptr ||
|
||||
time == 0) {
|
||||
return false;
|
||||
}
|
||||
if (nodeStateBuf_.size() < animatableNodeCount_) {
|
||||
nodeStateBuf_.resize(animatableNodeCount_);
|
||||
if (mi.nodeStateBuf.size() < mi.animatableNodeCount) {
|
||||
mi.nodeStateBuf.resize(mi.animatableNodeCount);
|
||||
}
|
||||
XrRenderModelStateGetInfoEXT gi{XR_TYPE_RENDER_MODEL_STATE_GET_INFO_EXT};
|
||||
gi.displayTime = time;
|
||||
XrRenderModelStateEXT st{XR_TYPE_RENDER_MODEL_STATE_EXT};
|
||||
st.nodeStateCount = static_cast<uint32_t>(nodeStateBuf_.size());
|
||||
st.nodeStates = nodeStateBuf_.data();
|
||||
if (XR_FAILED(pfnGetState_(model_, &gi, &st))) {
|
||||
st.nodeStateCount = static_cast<uint32_t>(mi.nodeStateBuf.size());
|
||||
st.nodeStates = mi.nodeStateBuf.data();
|
||||
if (XR_FAILED(pfnGetState_(mi.model, &gi, &st))) {
|
||||
return false;
|
||||
}
|
||||
if (st.nodeStateCount > nodeStateBuf_.size()) { // grew; retry once
|
||||
nodeStateBuf_.resize(st.nodeStateCount);
|
||||
st.nodeStateCount = static_cast<uint32_t>(nodeStateBuf_.size());
|
||||
st.nodeStates = nodeStateBuf_.data();
|
||||
if (XR_FAILED(pfnGetState_(model_, &gi, &st))) {
|
||||
if (st.nodeStateCount > mi.nodeStateBuf.size()) { // grew; retry once
|
||||
mi.nodeStateBuf.resize(st.nodeStateCount);
|
||||
st.nodeStateCount = static_cast<uint32_t>(mi.nodeStateBuf.size());
|
||||
st.nodeStates = mi.nodeStateBuf.data();
|
||||
if (XR_FAILED(pfnGetState_(mi.model, &gi, &st))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
out.resize(st.nodeStateCount);
|
||||
for (uint32_t i = 0; i < st.nodeStateCount; ++i) {
|
||||
const XrRenderModelNodeStateEXT& n = nodeStateBuf_[i];
|
||||
const XrRenderModelNodeStateEXT& n = mi.nodeStateBuf[i];
|
||||
NodeState& o = out[i];
|
||||
o.pos[0] = n.nodePose.position.x;
|
||||
o.pos[1] = n.nodePose.position.y;
|
||||
@@ -210,17 +265,21 @@ bool RenderModelSystem::nodeStates(XrTime time, std::vector<NodeState>& out) {
|
||||
}
|
||||
|
||||
void RenderModelSystem::shutdown() {
|
||||
if (modelSpace_ != XR_NULL_HANDLE) {
|
||||
xrDestroySpace(modelSpace_);
|
||||
modelSpace_ = XR_NULL_HANDLE;
|
||||
}
|
||||
if (asset_ != XR_NULL_HANDLE && pfnDestroyAsset_ != nullptr) {
|
||||
pfnDestroyAsset_(asset_);
|
||||
asset_ = XR_NULL_HANDLE;
|
||||
}
|
||||
if (model_ != XR_NULL_HANDLE && pfnDestroyModel_ != nullptr) {
|
||||
pfnDestroyModel_(model_);
|
||||
model_ = XR_NULL_HANDLE;
|
||||
for (int h = 0; h < 2; ++h) {
|
||||
ModelInfo& mi = models_[h];
|
||||
if (mi.space != XR_NULL_HANDLE) {
|
||||
xrDestroySpace(mi.space);
|
||||
mi.space = XR_NULL_HANDLE;
|
||||
}
|
||||
if (mi.asset != XR_NULL_HANDLE && pfnDestroyAsset_ != nullptr) {
|
||||
pfnDestroyAsset_(mi.asset);
|
||||
mi.asset = XR_NULL_HANDLE;
|
||||
}
|
||||
if (mi.model != XR_NULL_HANDLE && pfnDestroyModel_ != nullptr) {
|
||||
pfnDestroyModel_(mi.model);
|
||||
mi.model = XR_NULL_HANDLE;
|
||||
}
|
||||
mi.ready = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,9 +9,7 @@
|
||||
namespace pixelvr {
|
||||
|
||||
// Fetches (and, in later stages, renders) the runtime-provided controller models
|
||||
// via XR_EXT_render_model. Stage 2 scope: resolve the extension entry points,
|
||||
// enumerate the active interaction render models, and pull the first one's
|
||||
// glTF/GLB asset bytes so the renderer can parse and draw it.
|
||||
// via XR_EXT_render_model for both left (0) and right (1) hands.
|
||||
class RenderModelSystem {
|
||||
public:
|
||||
// Resolves the extension function pointers. `enabled` is whether the three
|
||||
@@ -20,22 +18,33 @@ public:
|
||||
|
||||
bool available() const { return available_; }
|
||||
|
||||
// Lazily enumerates + fetches the first controller model's glTF the first
|
||||
// time a controller is active. Safe to call every frame. Returns true once
|
||||
// the asset bytes are ready.
|
||||
// Lazily enumerates + fetches controller models for both hands.
|
||||
// Safe to call every frame.
|
||||
bool poll();
|
||||
|
||||
bool assetReady() const { return !gltf_.empty(); }
|
||||
const std::vector<uint8_t>& gltf() const { return gltf_; }
|
||||
uint32_t animatableNodeCount() const { return animatableNodeCount_; }
|
||||
bool assetReady(int hand = 1) const {
|
||||
return hand >= 0 && hand < 2 && models_[hand].ready && !models_[hand].gltf.empty();
|
||||
}
|
||||
const std::vector<uint8_t>& gltf(int hand = 1) const {
|
||||
static const std::vector<uint8_t> kEmpty;
|
||||
return (hand >= 0 && hand < 2) ? models_[hand].gltf : kEmpty;
|
||||
}
|
||||
uint32_t animatableNodeCount(int hand = 1) const {
|
||||
return (hand >= 0 && hand < 2) ? models_[hand].animatableNodeCount : 0;
|
||||
}
|
||||
|
||||
// Locates the render-model space in `base` at `time` (the pose the runtime
|
||||
// expects the glTF to be rendered at). False if not fetched or untracked.
|
||||
bool locate(XrSpace base, XrTime time, float outPos[3], float outQuat[4]) const;
|
||||
// Locates the render-model space in `base` at `time` for `hand` (0 = left, 1 = right).
|
||||
bool locate(int hand, XrSpace base, XrTime time, float outPos[3], float outQuat[4]) const;
|
||||
bool locate(XrSpace base, XrTime time, float outPos[3], float outQuat[4]) const {
|
||||
return locate(1, base, time, outPos, outQuat);
|
||||
}
|
||||
|
||||
// Names of the model's animatable nodes, in the same order as the node
|
||||
// states returned by nodeStates() (maps a node state to glTF geometry).
|
||||
const std::vector<std::string>& nodeNames() const { return nodeNames_; }
|
||||
const std::vector<std::string>& nodeNames(int hand = 1) const {
|
||||
static const std::vector<std::string> kEmpty;
|
||||
return (hand >= 0 && hand < 2) ? models_[hand].nodeNames : kEmpty;
|
||||
}
|
||||
|
||||
// Animated per-node transform the runtime wants applied this frame (drives
|
||||
// button-press movement, trigger pull, thumbstick tilt, etc.).
|
||||
@@ -44,28 +53,37 @@ public:
|
||||
float quat[4] = {0.0f, 0.0f, 0.0f, 1.0f};
|
||||
bool visible = true;
|
||||
};
|
||||
// Fills `out` (parallel to nodeNames()) for `time`. False if unavailable.
|
||||
bool nodeStates(XrTime time, std::vector<NodeState>& out);
|
||||
// Fills `out` (parallel to nodeNames()) for `hand` at `time`. False if unavailable.
|
||||
bool nodeStates(int hand, XrTime time, std::vector<NodeState>& out);
|
||||
bool nodeStates(XrTime time, std::vector<NodeState>& out) {
|
||||
return nodeStates(1, time, out);
|
||||
}
|
||||
|
||||
void shutdown();
|
||||
|
||||
private:
|
||||
bool fetchFirstModel();
|
||||
bool fetchModels();
|
||||
|
||||
XrInstance instance_ = XR_NULL_HANDLE;
|
||||
XrSession session_ = XR_NULL_HANDLE;
|
||||
bool available_ = false;
|
||||
bool done_ = false; // asset fetched (or permanently failed)
|
||||
bool done_ = false; // all models fetched (or permanently failed)
|
||||
|
||||
XrRenderModelEXT model_ = XR_NULL_HANDLE;
|
||||
XrRenderModelAssetEXT asset_ = XR_NULL_HANDLE;
|
||||
XrSpace modelSpace_ = XR_NULL_HANDLE;
|
||||
std::vector<uint8_t> gltf_;
|
||||
uint32_t animatableNodeCount_ = 0;
|
||||
std::vector<std::string> nodeNames_;
|
||||
std::vector<XrRenderModelNodeStateEXT> nodeStateBuf_; // reused per frame
|
||||
struct ModelInfo {
|
||||
XrRenderModelIdEXT id = XR_NULL_RENDER_MODEL_ID_EXT;
|
||||
XrRenderModelEXT model = XR_NULL_HANDLE;
|
||||
XrRenderModelAssetEXT asset = XR_NULL_HANDLE;
|
||||
XrSpace space = XR_NULL_HANDLE;
|
||||
std::vector<uint8_t> gltf;
|
||||
std::vector<std::string> nodeNames;
|
||||
std::vector<XrRenderModelNodeStateEXT> nodeStateBuf; // reused per frame
|
||||
uint32_t animatableNodeCount = 0;
|
||||
bool ready = false;
|
||||
};
|
||||
ModelInfo models_[2]; // 0 = left, 1 = right
|
||||
|
||||
PFN_xrEnumerateInteractionRenderModelIdsEXT pfnEnumerateIds_ = nullptr;
|
||||
PFN_xrEnumerateRenderModelSubactionPathsEXT pfnEnumerateSubactionPaths_ = nullptr;
|
||||
PFN_xrCreateRenderModelEXT pfnCreateModel_ = nullptr;
|
||||
PFN_xrGetRenderModelPropertiesEXT pfnGetProps_ = nullptr;
|
||||
PFN_xrCreateRenderModelAssetEXT pfnCreateAsset_ = nullptr;
|
||||
|
||||
@@ -0,0 +1,555 @@
|
||||
#include "vr_keyboard.hpp"
|
||||
#include "util/logging.hpp"
|
||||
#include <cstring>
|
||||
#include <cmath>
|
||||
#include <chrono>
|
||||
|
||||
#if defined(__linux__) || defined(__APPLE__)
|
||||
#include <dlfcn.h>
|
||||
#if defined(__linux__)
|
||||
#include <fcntl.h>
|
||||
#include <sys/mman.h>
|
||||
#include <unistd.h>
|
||||
#include <dirent.h>
|
||||
#include <sys/stat.h>
|
||||
#include <algorithm>
|
||||
#endif
|
||||
#else
|
||||
#include <windows.h>
|
||||
#endif
|
||||
|
||||
#include "openvr.h"
|
||||
|
||||
namespace pixelvr {
|
||||
|
||||
namespace {
|
||||
|
||||
typedef intptr_t (*Fn_VR_InitInternal)(vr::EVRInitError*, vr::EVRApplicationType);
|
||||
typedef void (*Fn_VR_ShutdownInternal)();
|
||||
typedef intptr_t (*Fn_VR_GetGenericInterface)(const char*, vr::EVRInitError*);
|
||||
|
||||
Fn_VR_InitInternal g_VR_InitInternal = nullptr;
|
||||
Fn_VR_ShutdownInternal g_VR_ShutdownInternal = nullptr;
|
||||
Fn_VR_GetGenericInterface g_VR_GetGenericInterface = nullptr;
|
||||
|
||||
static uint64_t sKeyboardNonce = 1000;
|
||||
|
||||
#if defined(__linux__)
|
||||
static void clearOrSetShmKeyboardBuffer(const std::string& text) {
|
||||
// SteamVR stores the active keyboard buffer across all GamepadUI CEF instances
|
||||
// in a shared memory segment of size 0x40cc0 (265408 bytes) named /dev/shm/u<uid>-Shm_*
|
||||
// at offset 0x3fe60.
|
||||
DIR* dir = opendir("/dev/shm");
|
||||
if (!dir) return;
|
||||
|
||||
struct dirent* entry;
|
||||
while ((entry = readdir(dir)) != nullptr) {
|
||||
if (std::strstr(entry->d_name, "Shm_") != nullptr) {
|
||||
std::string path = std::string("/dev/shm/") + entry->d_name;
|
||||
struct stat st{};
|
||||
if (stat(path.c_str(), &st) == 0 && st.st_size == 0x40cc0) {
|
||||
int fd = open(path.c_str(), O_RDWR);
|
||||
if (fd >= 0) {
|
||||
void* ptr = mmap(nullptr, 0x40cc0, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
|
||||
close(fd);
|
||||
if (ptr != MAP_FAILED) {
|
||||
char* kbBuf = reinterpret_cast<char*>(ptr) + 0x3fe60;
|
||||
std::memset(kbBuf, 0, 1024);
|
||||
if (!text.empty()) {
|
||||
std::size_t copyLen = std::min(text.length(), static_cast<std::size_t>(1023));
|
||||
std::memcpy(kbBuf, text.c_str(), copyLen);
|
||||
}
|
||||
msync(ptr, 0x40cc0, MS_SYNC);
|
||||
munmap(ptr, 0x40cc0);
|
||||
PIXELVR_LOG_INFO("VrKeyboard: Updated shm %s at 0x3fe60 to '%s'",
|
||||
entry->d_name, text.c_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
closedir(dir);
|
||||
}
|
||||
#else
|
||||
static void clearOrSetShmKeyboardBuffer(const std::string&) {}
|
||||
#endif
|
||||
|
||||
} // namespace
|
||||
|
||||
VrKeyboard& VrKeyboard::instance() {
|
||||
static VrKeyboard sInstance;
|
||||
return sInstance;
|
||||
}
|
||||
|
||||
VrKeyboard::~VrKeyboard() {
|
||||
shutdown();
|
||||
}
|
||||
|
||||
bool VrKeyboard::init() {
|
||||
if (available_) return true;
|
||||
|
||||
#if defined(__linux__)
|
||||
const char* paths[] = {
|
||||
"/opt/steamvr/bin/linuxarm64/libopenvr_api.so",
|
||||
"/opt/steamvr/bin/linux64/libopenvr_api.so",
|
||||
"libopenvr_api.so"
|
||||
};
|
||||
for (const char* path : paths) {
|
||||
libHandle_ = dlopen(path, RTLD_NOW | RTLD_LOCAL);
|
||||
if (libHandle_) {
|
||||
PIXELVR_LOG_INFO("VrKeyboard: Loaded %s", path);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!libHandle_) {
|
||||
PIXELVR_LOG_WARN("VrKeyboard: Could not find or load libopenvr_api.so: %s", dlerror());
|
||||
return false;
|
||||
}
|
||||
|
||||
g_VR_InitInternal = reinterpret_cast<Fn_VR_InitInternal>(dlsym(libHandle_, "VR_InitInternal"));
|
||||
g_VR_ShutdownInternal = reinterpret_cast<Fn_VR_ShutdownInternal>(dlsym(libHandle_, "VR_ShutdownInternal"));
|
||||
g_VR_GetGenericInterface = reinterpret_cast<Fn_VR_GetGenericInterface>(dlsym(libHandle_, "VR_GetGenericInterface"));
|
||||
#elif defined(_WIN32)
|
||||
HMODULE hMod = LoadLibraryA("openvr_api.dll");
|
||||
if (!hMod) {
|
||||
return false;
|
||||
}
|
||||
libHandle_ = hMod;
|
||||
g_VR_InitInternal = reinterpret_cast<Fn_VR_InitInternal>(GetProcAddress(hMod, "VR_InitInternal"));
|
||||
g_VR_ShutdownInternal = reinterpret_cast<Fn_VR_ShutdownInternal>(GetProcAddress(hMod, "VR_ShutdownInternal"));
|
||||
g_VR_GetGenericInterface = reinterpret_cast<Fn_VR_GetGenericInterface>(GetProcAddress(hMod, "VR_GetGenericInterface"));
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
|
||||
if (!g_VR_InitInternal || !g_VR_ShutdownInternal || !g_VR_GetGenericInterface) {
|
||||
PIXELVR_LOG_WARN("VrKeyboard: OpenVR symbols not resolved");
|
||||
shutdown();
|
||||
return false;
|
||||
}
|
||||
|
||||
vr::EVRInitError err = vr::VRInitError_None;
|
||||
uint32_t token = static_cast<uint32_t>(g_VR_InitInternal(&err, vr::VRApplication_Overlay));
|
||||
if (token == 0 || err != vr::VRInitError_None) {
|
||||
PIXELVR_LOG_WARN("VrKeyboard: OpenVR overlay init returned %d", static_cast<int>(err));
|
||||
shutdown();
|
||||
return false;
|
||||
}
|
||||
|
||||
vr::IVRSystem* sys = reinterpret_cast<vr::IVRSystem*>(
|
||||
g_VR_GetGenericInterface(vr::IVRSystem_Version, &err));
|
||||
if (!sys || err != vr::VRInitError_None) {
|
||||
PIXELVR_LOG_WARN("VrKeyboard: Failed to get IVRSystem interface %s (err=%d)",
|
||||
vr::IVRSystem_Version, static_cast<int>(err));
|
||||
shutdown();
|
||||
return false;
|
||||
}
|
||||
|
||||
vr::IVROverlay* ov = reinterpret_cast<vr::IVROverlay*>(
|
||||
g_VR_GetGenericInterface(vr::IVROverlay_Version, &err));
|
||||
if (!ov || err != vr::VRInitError_None) {
|
||||
PIXELVR_LOG_WARN("VrKeyboard: Failed to get IVROverlay interface %s (err=%d)",
|
||||
vr::IVROverlay_Version, static_cast<int>(err));
|
||||
shutdown();
|
||||
return false;
|
||||
}
|
||||
|
||||
vrSystem_ = sys;
|
||||
vrOverlay_ = ov;
|
||||
available_ = true;
|
||||
|
||||
// Clear any stale shared memory keyboard buffer left by previous sessions
|
||||
clearOrSetShmKeyboardBuffer("");
|
||||
|
||||
PIXELVR_LOG_INFO("VrKeyboard: Successfully initialized SteamVR overlay interface (sys=%p, ov=%p)",
|
||||
reinterpret_cast<void*>(sys), reinterpret_cast<void*>(ov));
|
||||
return true;
|
||||
}
|
||||
|
||||
void VrKeyboard::shutdown() {
|
||||
if (!available_) return;
|
||||
available_ = false;
|
||||
if (isOpen_) {
|
||||
hide();
|
||||
}
|
||||
if (g_VR_ShutdownInternal) {
|
||||
auto fn = g_VR_ShutdownInternal;
|
||||
g_VR_ShutdownInternal = nullptr;
|
||||
fn();
|
||||
}
|
||||
vrSystem_ = nullptr;
|
||||
vrOverlay_ = nullptr;
|
||||
if (libHandle_) {
|
||||
#if defined(__linux__) || defined(__APPLE__)
|
||||
dlclose(libHandle_);
|
||||
#elif defined(_WIN32)
|
||||
FreeLibrary(reinterpret_cast<HMODULE>(libHandle_));
|
||||
#endif
|
||||
libHandle_ = nullptr;
|
||||
}
|
||||
g_VR_InitInternal = nullptr;
|
||||
g_VR_GetGenericInterface = nullptr;
|
||||
isOpen_ = false;
|
||||
doneSubmitted_ = false;
|
||||
kbConfigured_ = false;
|
||||
currentNonce_ = 0;
|
||||
}
|
||||
|
||||
bool VrKeyboard::configureKeyboardOverlay() {
|
||||
auto* ov = reinterpret_cast<vr::IVROverlay*>(vrOverlay_);
|
||||
if (!ov) return false;
|
||||
|
||||
bool foundAny = false;
|
||||
vr::HmdMatrix34_t mat{};
|
||||
for (int r = 0; r < 3; ++r) {
|
||||
for (int c = 0; c < 4; ++c) {
|
||||
mat.m[r][c] = kbMatStage_[r][c];
|
||||
}
|
||||
}
|
||||
|
||||
auto configureHandle = [&](vr::VROverlayHandle_t h, const char* name) {
|
||||
ov->SetOverlayTransformAbsolute(h, vr::TrackingUniverseStanding, &mat);
|
||||
ov->SetOverlayWidthInMeters(h, 1.05f);
|
||||
ov->SetOverlayInputMethod(h, vr::VROverlayInputMethod_Mouse);
|
||||
ov->SetOverlayFlag(h, vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible, true);
|
||||
ov->SetOverlayFlag(h, vr::VROverlayFlags_SendVRDiscreteScrollEvents, true);
|
||||
ov->ShowOverlay(h);
|
||||
foundAny = true;
|
||||
PIXELVR_LOG_INFO("VrKeyboard: Configured keyboard overlay %llu ('%s') with interactive laser mouse at standing pos (%.2f, %.2f, %.2f)",
|
||||
static_cast<unsigned long long>(h), name ? name : "unknown",
|
||||
mat.m[0][3], mat.m[1][3], mat.m[2][3]);
|
||||
};
|
||||
|
||||
// 1. Check known specific overlay keys
|
||||
const char* kbOverlayNames[] = {
|
||||
"VRKeyboard_uid0",
|
||||
"VRKeyboard_uid1",
|
||||
"VRKeyboard",
|
||||
"valve.steam.gamepadui.keyboard",
|
||||
"system.keyboard"
|
||||
};
|
||||
for (const char* name : kbOverlayNames) {
|
||||
vr::VROverlayHandle_t hKb = vr::k_ulOverlayHandleInvalid;
|
||||
if (ov->FindOverlay(name, &hKb) == vr::VROverlayError_None && hKb != vr::k_ulOverlayHandleInvalid) {
|
||||
configureHandle(hKb, name);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Scan active overlay handles (1..128) to catch any dynamic uid variations (e.g. VRKeyboard_uid2726460308)
|
||||
for (uint64_t h = 1; h <= 128; ++h) {
|
||||
char keyBuf[256] = {0};
|
||||
vr::EVROverlayError kErr = vr::VROverlayError_None;
|
||||
uint32_t len = ov->GetOverlayKey(h, keyBuf, sizeof(keyBuf), &kErr);
|
||||
if (kErr == vr::VROverlayError_None && len > 0) {
|
||||
if (std::strstr(keyBuf, "keyboard") != nullptr ||
|
||||
std::strstr(keyBuf, "Keyboard") != nullptr ||
|
||||
std::strstr(keyBuf, "VRKeyboard") != nullptr) {
|
||||
configureHandle(h, keyBuf);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (foundAny) {
|
||||
kbConfigured_ = true;
|
||||
}
|
||||
return foundAny;
|
||||
}
|
||||
|
||||
bool VrKeyboard::show(const std::string& description,
|
||||
const std::string& existingText,
|
||||
bool isPassword,
|
||||
int maxChars,
|
||||
const float* headPosLocal,
|
||||
const float* headForwardLocal,
|
||||
const float* headPosStage,
|
||||
const float* headForwardStage) {
|
||||
if (!available_ && !init()) {
|
||||
return false;
|
||||
}
|
||||
auto* ov = reinterpret_cast<vr::IVROverlay*>(vrOverlay_);
|
||||
if (!ov) return false;
|
||||
|
||||
if (isOpen_) {
|
||||
hide();
|
||||
}
|
||||
// Explicitly hide any existing keyboard session in SteamVR to clear lingering state
|
||||
ov->HideKeyboard();
|
||||
|
||||
// Reset shared memory keyboard buffer to existingText
|
||||
clearOrSetShmKeyboardBuffer(existingText);
|
||||
|
||||
currentNonce_ = ++sKeyboardNonce;
|
||||
description_ = description;
|
||||
existingText_ = existingText;
|
||||
currentText_ = existingText;
|
||||
isPassword_ = isPassword;
|
||||
maxChars_ = maxChars;
|
||||
doneSubmitted_ = false;
|
||||
userTyped_ = false;
|
||||
|
||||
const vr::EGamepadTextInputMode mode = isPassword
|
||||
? vr::k_EGamepadTextInputModePassword
|
||||
: vr::k_EGamepadTextInputModeNormal;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 1. Calculate Local transform (for OpenXR laser pointer collision in VulkanRenderer)
|
||||
// -------------------------------------------------------------------------
|
||||
float hx = 0.0f, hy = 0.0f, hz = 0.0f;
|
||||
float fx = 0.0f, fz = -1.0f;
|
||||
if (headPosLocal && headForwardLocal) {
|
||||
hx = headPosLocal[0];
|
||||
hy = headPosLocal[1];
|
||||
hz = headPosLocal[2];
|
||||
fx = headForwardLocal[0];
|
||||
fz = headForwardLocal[2];
|
||||
}
|
||||
float flen = std::sqrt(fx * fx + fz * fz);
|
||||
if (flen < 1e-3f) {
|
||||
fx = 0.0f;
|
||||
fz = -1.0f;
|
||||
} else {
|
||||
fx /= flen;
|
||||
fz /= flen;
|
||||
}
|
||||
const float yawLocal = std::atan2(-fx, -fz);
|
||||
|
||||
// Keyboard placed 1.15m in front of eyes, 0.22m below eye level.
|
||||
// The floating modal dialog box in VulkanRenderer ends at ~hy + 0.03m; this places the top
|
||||
// of the keyboard (half-height 0.20m) at ~hy - 0.02m, creating an elegant ~5cm vertical gap
|
||||
// directly beneath the modal dialog.
|
||||
const float dist = 1.15f;
|
||||
const float kbLocalX = hx + fx * dist;
|
||||
const float kbLocalY = hy - 0.22f;
|
||||
const float kbLocalZ = hz + fz * dist;
|
||||
|
||||
// Tilt upward toward eyes by -0.20 rad (~11.5 degrees)
|
||||
const float tilt = -0.20f;
|
||||
vr::HmdMatrix34_t kbMatLocal{};
|
||||
kbMatLocal.m[0][0] = std::cos(yawLocal);
|
||||
kbMatLocal.m[0][1] = std::sin(yawLocal) * std::sin(tilt);
|
||||
kbMatLocal.m[0][2] = std::sin(yawLocal) * std::cos(tilt);
|
||||
kbMatLocal.m[0][3] = kbLocalX;
|
||||
|
||||
kbMatLocal.m[1][0] = 0.0f;
|
||||
kbMatLocal.m[1][1] = std::cos(tilt);
|
||||
kbMatLocal.m[1][2] = -std::sin(tilt);
|
||||
kbMatLocal.m[1][3] = kbLocalY;
|
||||
|
||||
kbMatLocal.m[2][0] = -std::sin(yawLocal);
|
||||
kbMatLocal.m[2][1] = std::cos(yawLocal) * std::sin(tilt);
|
||||
kbMatLocal.m[2][2] = std::cos(yawLocal) * std::cos(tilt);
|
||||
kbMatLocal.m[2][3] = kbLocalZ;
|
||||
|
||||
for (int r = 0; r < 3; ++r) {
|
||||
for (int c = 0; c < 4; ++c) {
|
||||
kbMatLocal_[r][c] = kbMatLocal.m[r][c];
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 2. Calculate Stage transform (for SteamVR TrackingUniverseStanding overlay rendering)
|
||||
// -------------------------------------------------------------------------
|
||||
float shx = hx, shy = hy + 1.65f, shz = hz;
|
||||
float sfx = fx, sfz = fz;
|
||||
if (headPosStage && headForwardStage) {
|
||||
shx = headPosStage[0];
|
||||
shy = headPosStage[1];
|
||||
shz = headPosStage[2];
|
||||
sfx = headForwardStage[0];
|
||||
sfz = headForwardStage[2];
|
||||
}
|
||||
float sflen = std::sqrt(sfx * sfx + sfz * sfz);
|
||||
if (sflen < 1e-3f) {
|
||||
sfx = 0.0f;
|
||||
sfz = -1.0f;
|
||||
} else {
|
||||
sfx /= sflen;
|
||||
sfz /= sflen;
|
||||
}
|
||||
const float yawStage = std::atan2(-sfx, -sfz);
|
||||
|
||||
const float kbStageX = shx + sfx * dist;
|
||||
const float kbStageY = shy - 0.22f; // In standing space (floor=0), shy ~1.65m, so kbStageY ~1.43m above floor!
|
||||
const float kbStageZ = shz + sfz * dist;
|
||||
|
||||
vr::HmdMatrix34_t kbMatStage{};
|
||||
kbMatStage.m[0][0] = std::cos(yawStage);
|
||||
kbMatStage.m[0][1] = std::sin(yawStage) * std::sin(tilt);
|
||||
kbMatStage.m[0][2] = std::sin(yawStage) * std::cos(tilt);
|
||||
kbMatStage.m[0][3] = kbStageX;
|
||||
|
||||
kbMatStage.m[1][0] = 0.0f;
|
||||
kbMatStage.m[1][1] = std::cos(tilt);
|
||||
kbMatStage.m[1][2] = -std::sin(tilt);
|
||||
kbMatStage.m[1][3] = kbStageY;
|
||||
|
||||
kbMatStage.m[2][0] = -std::sin(yawStage);
|
||||
kbMatStage.m[2][1] = std::cos(yawStage) * std::sin(tilt);
|
||||
kbMatStage.m[2][2] = std::cos(yawStage) * std::cos(tilt);
|
||||
kbMatStage.m[2][3] = kbStageZ;
|
||||
|
||||
for (int r = 0; r < 3; ++r) {
|
||||
for (int c = 0; c < 4; ++c) {
|
||||
kbMatStage_[r][c] = kbMatStage.m[r][c];
|
||||
}
|
||||
}
|
||||
kbConfigured_ = false;
|
||||
|
||||
// 1. Set keyboard transform in standing universe BEFORE ShowKeyboard
|
||||
ov->SetKeyboardTransformAbsolute(vr::TrackingUniverseStanding, &kbMatStage);
|
||||
|
||||
// 2. Invoke virtual keyboard with Modal flag
|
||||
vr::EVROverlayError err = ov->ShowKeyboard(
|
||||
mode,
|
||||
vr::k_EGamepadTextInputLineModeSingleLine,
|
||||
vr::KeyboardFlag_Modal | vr::KeyboardFlag_Minimal,
|
||||
description.c_str(),
|
||||
static_cast<uint32_t>(maxChars),
|
||||
existingText.c_str(),
|
||||
currentNonce_);
|
||||
|
||||
if (err != vr::VROverlayError_None) {
|
||||
PIXELVR_LOG_WARN("VrKeyboard: ShowKeyboard failed with error %d", static_cast<int>(err));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Reinforce existingText in shared memory so GamepadUI CEF reads it cleanly
|
||||
clearOrSetShmKeyboardBuffer(existingText);
|
||||
|
||||
// 3. Reinforce keyboard transform AFTER ShowKeyboard
|
||||
ov->SetKeyboardTransformAbsolute(vr::TrackingUniverseStanding, &kbMatStage);
|
||||
|
||||
// 4. Configure overlay laser and transform immediately if already present
|
||||
configureKeyboardOverlay();
|
||||
|
||||
isOpen_ = true;
|
||||
pollFramesSinceOpen_ = 0;
|
||||
PIXELVR_LOG_INFO("VrKeyboard: Shown virtual keyboard for '%s' (nonce %llu, existing='%s') at stage pos (%.2f, %.2f, %.2f) local pos (%.2f, %.2f, %.2f)",
|
||||
description.c_str(), static_cast<unsigned long long>(currentNonce_),
|
||||
existingText.c_str(), kbStageX, kbStageY, kbStageZ, kbLocalX, kbLocalY, kbLocalZ);
|
||||
return true;
|
||||
}
|
||||
|
||||
void VrKeyboard::hide() {
|
||||
if (!isOpen_) return;
|
||||
auto* ov = reinterpret_cast<vr::IVROverlay*>(vrOverlay_);
|
||||
if (ov) {
|
||||
ov->HideKeyboard();
|
||||
}
|
||||
clearOrSetShmKeyboardBuffer("");
|
||||
isOpen_ = false;
|
||||
kbConfigured_ = false;
|
||||
doneSubmitted_ = false;
|
||||
userTyped_ = false;
|
||||
currentNonce_ = 0;
|
||||
currentText_.clear();
|
||||
existingText_.clear();
|
||||
PIXELVR_LOG_INFO("VrKeyboard: Hidden virtual keyboard");
|
||||
}
|
||||
|
||||
void VrKeyboard::poll(std::function<void(const std::string&)> onDone,
|
||||
std::function<void()> onCancel) {
|
||||
if (!available_ || !isOpen_) return;
|
||||
auto* sys = reinterpret_cast<vr::IVRSystem*>(vrSystem_);
|
||||
auto* ov = reinterpret_cast<vr::IVROverlay*>(vrOverlay_);
|
||||
if (!sys || !ov) return;
|
||||
|
||||
vr::VREvent_t event{};
|
||||
while (sys->PollNextEvent(&event, sizeof(event))) {
|
||||
if (event.eventType == vr::VREvent_KeyboardCharInput) {
|
||||
// Discard any events from old or stale keyboard sessions
|
||||
if (event.data.keyboard.uUserValue != 0 && event.data.keyboard.uUserValue != currentNonce_) {
|
||||
PIXELVR_LOG_INFO("VrKeyboard: Ignored stale CharInput (nonce %llu != current %llu)",
|
||||
static_cast<unsigned long long>(event.data.keyboard.uUserValue),
|
||||
static_cast<unsigned long long>(currentNonce_));
|
||||
continue;
|
||||
}
|
||||
|
||||
const char* inStr = event.data.keyboard.cNewInput;
|
||||
if (inStr && inStr[0] != 0) {
|
||||
userTyped_ = true;
|
||||
const unsigned char firstByte = static_cast<unsigned char>(inStr[0]);
|
||||
if (firstByte == '\b' || firstByte == 0x7F || firstByte == 8) {
|
||||
// Backspace: pop last UTF-8 character
|
||||
while (!currentText_.empty() && (static_cast<unsigned char>(currentText_.back()) & 0xC0) == 0x80) {
|
||||
currentText_.pop_back();
|
||||
}
|
||||
if (!currentText_.empty()) {
|
||||
currentText_.pop_back();
|
||||
}
|
||||
PIXELVR_LOG_INFO("VrKeyboard: Backspace pressed, text now: '%s'", currentText_.c_str());
|
||||
} else if (firstByte == '\n' || firstByte == '\r') {
|
||||
isOpen_ = false;
|
||||
kbConfigured_ = false;
|
||||
doneSubmitted_ = true;
|
||||
std::string text = currentText_;
|
||||
ov->HideKeyboard();
|
||||
clearOrSetShmKeyboardBuffer("");
|
||||
currentText_.clear();
|
||||
existingText_.clear();
|
||||
userTyped_ = false;
|
||||
currentNonce_ = 0;
|
||||
PIXELVR_LOG_INFO("VrKeyboard: User submitted text via Return key: '%s'", text.c_str());
|
||||
if (onDone) {
|
||||
onDone(text);
|
||||
}
|
||||
return;
|
||||
} else if (firstByte >= 32) {
|
||||
if (currentText_.length() + std::strlen(inStr) <= static_cast<size_t>(maxChars_)) {
|
||||
currentText_ += inStr;
|
||||
}
|
||||
PIXELVR_LOG_INFO("VrKeyboard: Input '%s', text now: '%s'", inStr, currentText_.c_str());
|
||||
}
|
||||
}
|
||||
} else if (event.eventType == vr::VREvent_KeyboardDone) {
|
||||
isOpen_ = false;
|
||||
kbConfigured_ = false;
|
||||
doneSubmitted_ = true;
|
||||
std::string text = currentText_;
|
||||
ov->HideKeyboard();
|
||||
clearOrSetShmKeyboardBuffer("");
|
||||
currentText_.clear();
|
||||
existingText_.clear();
|
||||
userTyped_ = false;
|
||||
currentNonce_ = 0;
|
||||
PIXELVR_LOG_INFO("VrKeyboard: User submitted text via KeyboardDone: '%s'", text.c_str());
|
||||
if (onDone) {
|
||||
onDone(text);
|
||||
}
|
||||
return;
|
||||
} else if (event.eventType == vr::VREvent_KeyboardClosed ||
|
||||
event.eventType == vr::VREvent_KeyboardClosed_Global) {
|
||||
isOpen_ = false;
|
||||
kbConfigured_ = false;
|
||||
ov->HideKeyboard();
|
||||
clearOrSetShmKeyboardBuffer("");
|
||||
PIXELVR_LOG_INFO("VrKeyboard: Keyboard closed (doneSubmitted=%d)", doneSubmitted_ ? 1 : 0);
|
||||
if (!doneSubmitted_) {
|
||||
currentText_.clear();
|
||||
existingText_.clear();
|
||||
userTyped_ = false;
|
||||
currentNonce_ = 0;
|
||||
PIXELVR_LOG_INFO("VrKeyboard: Discarded input on KeyboardClosed");
|
||||
if (onCancel) {
|
||||
onCancel();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (isOpen_) {
|
||||
pollFramesSinceOpen_++;
|
||||
// Maintain and reinforce keyboard overlay configuration across the first 90 frames (~1 sec)
|
||||
// until successfully configured
|
||||
if (!kbConfigured_ && pollFramesSinceOpen_ < 90) {
|
||||
configureKeyboardOverlay();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::string VrKeyboard::currentText() const {
|
||||
return currentText_;
|
||||
}
|
||||
|
||||
} // namespace pixelvr
|
||||
@@ -0,0 +1,69 @@
|
||||
#pragma once
|
||||
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <cstdint>
|
||||
|
||||
namespace pixelvr {
|
||||
|
||||
class VrKeyboard {
|
||||
public:
|
||||
static VrKeyboard& instance();
|
||||
|
||||
// Initializes OpenVR overlay interface dynamically if SteamVR is running.
|
||||
bool init();
|
||||
void shutdown();
|
||||
|
||||
bool isAvailable() const { return available_; }
|
||||
bool isOpen() const { return isOpen_; }
|
||||
|
||||
// Opens the native SteamVR / SteamOS VR on-screen virtual keyboard.
|
||||
// e.g. description: "Enter SMB Server IP", existingText: "192.168.1.100", isPassword: false
|
||||
bool show(const std::string& description,
|
||||
const std::string& existingText,
|
||||
bool isPassword = false,
|
||||
int maxChars = 256,
|
||||
const float* headPosLocal = nullptr,
|
||||
const float* headForwardLocal = nullptr,
|
||||
const float* headPosStage = nullptr,
|
||||
const float* headForwardStage = nullptr);
|
||||
|
||||
// Closes the virtual keyboard if open.
|
||||
void hide();
|
||||
|
||||
// Call once per frame in the main event loop to poll OpenVR keyboard events.
|
||||
void poll(std::function<void(const std::string&)> onDone,
|
||||
std::function<void()> onCancel);
|
||||
|
||||
// Retrieves current entered buffer
|
||||
std::string currentText() const;
|
||||
const std::string& description() const { return description_; }
|
||||
bool isPassword() const { return isPassword_; }
|
||||
const float* transform() const { return &kbMatLocal_[0][0]; }
|
||||
|
||||
private:
|
||||
VrKeyboard() = default;
|
||||
~VrKeyboard();
|
||||
|
||||
bool configureKeyboardOverlay();
|
||||
|
||||
bool available_ = false;
|
||||
bool isOpen_ = false;
|
||||
bool doneSubmitted_ = false;
|
||||
bool userTyped_ = false;
|
||||
bool kbConfigured_ = false;
|
||||
int pollFramesSinceOpen_ = 0;
|
||||
float kbMatLocal_[3][4]{};
|
||||
float kbMatStage_[3][4]{};
|
||||
uint64_t currentNonce_ = 0;
|
||||
int maxChars_ = 256;
|
||||
std::string description_;
|
||||
std::string existingText_;
|
||||
std::string currentText_;
|
||||
bool isPassword_ = false;
|
||||
void* libHandle_ = nullptr;
|
||||
void* vrSystem_ = nullptr;
|
||||
void* vrOverlay_ = nullptr;
|
||||
};
|
||||
|
||||
} // namespace pixelvr
|
||||
@@ -1,8 +1,10 @@
|
||||
#include "xr/xr_context.hpp"
|
||||
|
||||
#include "xr/xr_result.hpp"
|
||||
#include "xr/vr_keyboard.hpp"
|
||||
#include "util/logging.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cstring>
|
||||
|
||||
@@ -33,6 +35,10 @@ XrContext::~XrContext() {
|
||||
if (appSpace_ != XR_NULL_HANDLE) {
|
||||
xrDestroySpace(appSpace_);
|
||||
}
|
||||
if (stageSpace_ != XR_NULL_HANDLE) {
|
||||
xrDestroySpace(stageSpace_);
|
||||
stageSpace_ = XR_NULL_HANDLE;
|
||||
}
|
||||
if (actionSet_ != XR_NULL_HANDLE) {
|
||||
xrDestroyActionSet(actionSet_);
|
||||
}
|
||||
@@ -215,6 +221,18 @@ bool XrContext::createReferenceSpace() {
|
||||
createInfo.poseInReferenceSpace.orientation.w = 1.0f;
|
||||
PIXELVR_XR_CHECK(instance_,
|
||||
xrCreateReferenceSpace(session_, &createInfo, &appSpace_));
|
||||
|
||||
// Create STAGE reference space (floor level tracking bounds, matching SteamVR TrackingUniverseStanding)
|
||||
XrReferenceSpaceCreateInfo stageInfo{XR_TYPE_REFERENCE_SPACE_CREATE_INFO};
|
||||
stageInfo.referenceSpaceType = XR_REFERENCE_SPACE_TYPE_STAGE;
|
||||
stageInfo.poseInReferenceSpace.orientation.w = 1.0f;
|
||||
XrResult stageRes = xrCreateReferenceSpace(session_, &stageInfo, &stageSpace_);
|
||||
if (XR_SUCCEEDED(stageRes)) {
|
||||
PIXELVR_LOG_INFO("XrContext: Successfully created STAGE reference space");
|
||||
} else {
|
||||
PIXELVR_LOG_WARN("XrContext: STAGE reference space not available (%d), using calibrated standing fallback", static_cast<int>(stageRes));
|
||||
stageSpace_ = XR_NULL_HANDLE;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -236,8 +254,18 @@ bool XrContext::createSwapchains(int64_t colorFormat) {
|
||||
|
||||
for (const auto& view : configViews_) {
|
||||
ViewSwapchain sc;
|
||||
sc.width = static_cast<int32_t>(view.recommendedImageRectWidth);
|
||||
sc.height = static_cast<int32_t>(view.recommendedImageRectHeight);
|
||||
// Boost swapchain resolution from SteamVR's conservative default (1728) to the headset's
|
||||
// native physical display resolution (2160x2160, 1.25x).
|
||||
// Rendering at 1:1 panel resolution eliminates compositor magnification blur and
|
||||
// edge shimmering during head movement in VR.
|
||||
const uint32_t maxW = (view.maxImageRectWidth > 0) ? view.maxImageRectWidth : 4096;
|
||||
const uint32_t maxH = (view.maxImageRectHeight > 0) ? view.maxImageRectHeight : 4096;
|
||||
const uint32_t targetW = std::clamp(static_cast<uint32_t>(view.recommendedImageRectWidth * 1.25f),
|
||||
view.recommendedImageRectWidth, maxW);
|
||||
const uint32_t targetH = std::clamp(static_cast<uint32_t>(view.recommendedImageRectHeight * 1.25f),
|
||||
view.recommendedImageRectHeight, maxH);
|
||||
sc.width = static_cast<int32_t>(targetW);
|
||||
sc.height = static_cast<int32_t>(targetH);
|
||||
sc.format = colorFormat;
|
||||
|
||||
XrSwapchainCreateInfo createInfo{XR_TYPE_SWAPCHAIN_CREATE_INFO};
|
||||
@@ -245,8 +273,8 @@ bool XrContext::createSwapchains(int64_t colorFormat) {
|
||||
XR_SWAPCHAIN_USAGE_SAMPLED_BIT;
|
||||
createInfo.format = colorFormat;
|
||||
createInfo.sampleCount = 1;
|
||||
createInfo.width = view.recommendedImageRectWidth;
|
||||
createInfo.height = view.recommendedImageRectHeight;
|
||||
createInfo.width = targetW;
|
||||
createInfo.height = targetH;
|
||||
createInfo.faceCount = 1;
|
||||
createInfo.arraySize = 1;
|
||||
createInfo.mipCount = 1;
|
||||
@@ -267,7 +295,9 @@ bool XrContext::createSwapchains(int64_t colorFormat) {
|
||||
swapchains_.push_back(std::move(sc));
|
||||
}
|
||||
|
||||
PIXELVR_LOG_INFO("Created %zu swapchains", swapchains_.size());
|
||||
PIXELVR_LOG_INFO("Created %zu swapchains (%dx%d per eye)", swapchains_.size(),
|
||||
swapchains_.empty() ? 0 : swapchains_[0].width,
|
||||
swapchains_.empty() ? 0 : swapchains_[0].height);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -337,6 +367,42 @@ bool XrContext::createActions() {
|
||||
gripPoseAction_ = XR_NULL_HANDLE;
|
||||
}
|
||||
}
|
||||
{
|
||||
XrActionCreateInfo ai{XR_TYPE_ACTION_CREATE_INFO};
|
||||
std::strcpy(ai.actionName, "grip_full_press");
|
||||
std::strcpy(ai.localizedActionName, "Grip Full Press Click");
|
||||
ai.actionType = XR_ACTION_TYPE_BOOLEAN_INPUT;
|
||||
ai.countSubactionPaths = 2;
|
||||
ai.subactionPaths = handPaths_;
|
||||
if (XR_FAILED(xrCreateAction(actionSet_, &ai, &gripFullPressAction_))) {
|
||||
PIXELVR_LOG_WARN("Failed to create grip full press action");
|
||||
gripFullPressAction_ = XR_NULL_HANDLE;
|
||||
}
|
||||
}
|
||||
{
|
||||
XrActionCreateInfo ai{XR_TYPE_ACTION_CREATE_INFO};
|
||||
std::strcpy(ai.actionName, "squeeze_val");
|
||||
std::strcpy(ai.localizedActionName, "Squeeze Value");
|
||||
ai.actionType = XR_ACTION_TYPE_FLOAT_INPUT;
|
||||
ai.countSubactionPaths = 2;
|
||||
ai.subactionPaths = handPaths_;
|
||||
if (XR_FAILED(xrCreateAction(actionSet_, &ai, &squeezeValueAction_))) {
|
||||
PIXELVR_LOG_WARN("Failed to create squeeze value action");
|
||||
squeezeValueAction_ = XR_NULL_HANDLE;
|
||||
}
|
||||
}
|
||||
{
|
||||
XrActionCreateInfo ai{XR_TYPE_ACTION_CREATE_INFO};
|
||||
std::strcpy(ai.actionName, "haptic_out");
|
||||
std::strcpy(ai.localizedActionName, "Haptic Output");
|
||||
ai.actionType = XR_ACTION_TYPE_VIBRATION_OUTPUT;
|
||||
ai.countSubactionPaths = 2;
|
||||
ai.subactionPaths = handPaths_;
|
||||
if (XR_FAILED(xrCreateAction(actionSet_, &ai, &hapticAction_))) {
|
||||
PIXELVR_LOG_WARN("Failed to create haptic action");
|
||||
hapticAction_ = XR_NULL_HANDLE;
|
||||
}
|
||||
}
|
||||
|
||||
// Physical buttons for user-configurable bindings (native Steam Frame paths).
|
||||
static const struct {
|
||||
@@ -344,11 +410,11 @@ bool XrContext::createActions() {
|
||||
const char* p0;
|
||||
const char* p1;
|
||||
} kBtns[] = {
|
||||
{"btn_a", "/user/hand/right/input/a/click", nullptr},
|
||||
{"btn_b", "/user/hand/right/input/b/click", nullptr},
|
||||
{"btn_x", "/user/hand/right/input/x/click", nullptr},
|
||||
{"btn_y", "/user/hand/right/input/y/click", nullptr},
|
||||
{"btn_menu", "/user/hand/right/input/menu/click", nullptr},
|
||||
{"btn_a", "/user/hand/right/input/a/click", "/user/hand/left/input/a/click"},
|
||||
{"btn_b", "/user/hand/right/input/b/click", "/user/hand/left/input/b/click"},
|
||||
{"btn_x", "/user/hand/left/input/x/click", "/user/hand/right/input/x/click"},
|
||||
{"btn_y", "/user/hand/left/input/y/click", "/user/hand/right/input/y/click"},
|
||||
{"btn_menu", "/user/hand/left/input/menu/click", "/user/hand/right/input/menu/click"},
|
||||
{"btn_view", "/user/hand/left/input/view/click", nullptr},
|
||||
{"btn_bumper", "/user/hand/left/input/bumper/click",
|
||||
"/user/hand/right/input/bumper/click"},
|
||||
@@ -397,7 +463,9 @@ bool XrContext::createActions() {
|
||||
{aimPoseAction_, path("/user/hand/left/input/aim/pose")},
|
||||
{aimPoseAction_, path("/user/hand/right/input/aim/pose")},
|
||||
{gripPoseAction_, path("/user/hand/left/input/grip/pose")},
|
||||
{gripPoseAction_, path("/user/hand/right/input/grip/pose")}});
|
||||
{gripPoseAction_, path("/user/hand/right/input/grip/pose")},
|
||||
{hapticAction_, path("/user/hand/left/output/haptic")},
|
||||
{hapticAction_, path("/user/hand/right/output/haptic")}});
|
||||
|
||||
suggest("/interaction_profiles/oculus/touch_controller",
|
||||
{{togglePlayAction_, path("/user/hand/right/input/a/click")},
|
||||
@@ -408,6 +476,8 @@ bool XrContext::createActions() {
|
||||
{selectAction_, path("/user/hand/left/input/trigger/value")},
|
||||
{recenterAction_, path("/user/hand/right/input/squeeze/value")},
|
||||
{recenterAction_, path("/user/hand/left/input/squeeze/value")},
|
||||
{squeezeValueAction_, path("/user/hand/right/input/squeeze/value")},
|
||||
{squeezeValueAction_, path("/user/hand/left/input/squeeze/value")},
|
||||
{thumbstickAction_, path("/user/hand/right/input/thumbstick")},
|
||||
{thumbstickAction_, path("/user/hand/left/input/thumbstick")},
|
||||
{thumbstickLeftAction_, path("/user/hand/left/input/thumbstick")},
|
||||
@@ -415,7 +485,18 @@ bool XrContext::createActions() {
|
||||
{aimPoseAction_, path("/user/hand/right/input/aim/pose")},
|
||||
{aimPoseAction_, path("/user/hand/left/input/aim/pose")},
|
||||
{gripPoseAction_, path("/user/hand/right/input/grip/pose")},
|
||||
{gripPoseAction_, path("/user/hand/left/input/grip/pose")}});
|
||||
{gripPoseAction_, path("/user/hand/left/input/grip/pose")},
|
||||
{hapticAction_, path("/user/hand/right/output/haptic")},
|
||||
{hapticAction_, path("/user/hand/left/output/haptic")},
|
||||
{buttonActions_[XrInputState::BtnA], path("/user/hand/right/input/a/click")},
|
||||
{buttonActions_[XrInputState::BtnB], path("/user/hand/right/input/b/click")},
|
||||
{buttonActions_[XrInputState::BtnX], path("/user/hand/left/input/x/click")},
|
||||
{buttonActions_[XrInputState::BtnY], path("/user/hand/left/input/y/click")},
|
||||
{buttonActions_[XrInputState::BtnMenu], path("/user/hand/left/input/menu/click")},
|
||||
{buttonActions_[XrInputState::BtnThumb], path("/user/hand/right/input/thumbstick/click")},
|
||||
{buttonActions_[XrInputState::BtnThumb], path("/user/hand/left/input/thumbstick/click")},
|
||||
{buttonActions_[XrInputState::BtnGrip], path("/user/hand/right/input/squeeze/value")},
|
||||
{buttonActions_[XrInputState::BtnGrip], path("/user/hand/left/input/squeeze/value")}});
|
||||
|
||||
suggest("/interaction_profiles/valve/index_controller",
|
||||
{{togglePlayAction_, path("/user/hand/right/input/a/click")},
|
||||
@@ -426,6 +507,10 @@ bool XrContext::createActions() {
|
||||
{selectAction_, path("/user/hand/left/input/trigger/value")},
|
||||
{recenterAction_, path("/user/hand/right/input/squeeze/value")},
|
||||
{recenterAction_, path("/user/hand/left/input/squeeze/value")},
|
||||
{gripFullPressAction_, path("/user/hand/right/input/squeeze/click")},
|
||||
{gripFullPressAction_, path("/user/hand/left/input/squeeze/click")},
|
||||
{squeezeValueAction_, path("/user/hand/right/input/squeeze/value")},
|
||||
{squeezeValueAction_, path("/user/hand/left/input/squeeze/value")},
|
||||
{thumbstickAction_, path("/user/hand/right/input/thumbstick")},
|
||||
{thumbstickAction_, path("/user/hand/left/input/thumbstick")},
|
||||
{thumbstickLeftAction_, path("/user/hand/left/input/thumbstick")},
|
||||
@@ -433,7 +518,17 @@ bool XrContext::createActions() {
|
||||
{aimPoseAction_, path("/user/hand/right/input/aim/pose")},
|
||||
{aimPoseAction_, path("/user/hand/left/input/aim/pose")},
|
||||
{gripPoseAction_, path("/user/hand/right/input/grip/pose")},
|
||||
{gripPoseAction_, path("/user/hand/left/input/grip/pose")}});
|
||||
{gripPoseAction_, path("/user/hand/left/input/grip/pose")},
|
||||
{hapticAction_, path("/user/hand/right/output/haptic")},
|
||||
{hapticAction_, path("/user/hand/left/output/haptic")},
|
||||
{buttonActions_[XrInputState::BtnA], path("/user/hand/right/input/a/click")},
|
||||
{buttonActions_[XrInputState::BtnA], path("/user/hand/left/input/a/click")},
|
||||
{buttonActions_[XrInputState::BtnB], path("/user/hand/right/input/b/click")},
|
||||
{buttonActions_[XrInputState::BtnB], path("/user/hand/left/input/b/click")},
|
||||
{buttonActions_[XrInputState::BtnThumb], path("/user/hand/right/input/thumbstick/click")},
|
||||
{buttonActions_[XrInputState::BtnThumb], path("/user/hand/left/input/thumbstick/click")},
|
||||
{buttonActions_[XrInputState::BtnGrip], path("/user/hand/right/input/squeeze/value")},
|
||||
{buttonActions_[XrInputState::BtnGrip], path("/user/hand/left/input/squeeze/value")}});
|
||||
|
||||
// Native Steam Frame profile: the runtime rejects the whole set if any path
|
||||
// is invalid, so probe each candidate individually, keep the accepted ones,
|
||||
@@ -466,6 +561,16 @@ bool XrContext::createActions() {
|
||||
probe(recenterAction_, "/user/hand/right/input/squeeze/value");
|
||||
probe(recenterAction_, "/user/hand/left/input/grip/value");
|
||||
probe(recenterAction_, "/user/hand/right/input/grip/value");
|
||||
probe(gripFullPressAction_, "/user/hand/left/input/squeeze/click");
|
||||
probe(gripFullPressAction_, "/user/hand/right/input/squeeze/click");
|
||||
probe(gripFullPressAction_, "/user/hand/left/input/grip/click");
|
||||
probe(gripFullPressAction_, "/user/hand/right/input/grip/click");
|
||||
probe(squeezeValueAction_, "/user/hand/left/input/squeeze/value");
|
||||
probe(squeezeValueAction_, "/user/hand/right/input/squeeze/value");
|
||||
probe(squeezeValueAction_, "/user/hand/left/input/grip/value");
|
||||
probe(squeezeValueAction_, "/user/hand/right/input/grip/value");
|
||||
probe(hapticAction_, "/user/hand/left/output/haptic");
|
||||
probe(hapticAction_, "/user/hand/right/output/haptic");
|
||||
probe(togglePlayAction_, "/user/hand/right/input/a/click");
|
||||
probe(browserAction_, "/user/hand/right/input/b/click");
|
||||
probe(browserAction_, "/user/hand/left/input/view/click");
|
||||
@@ -543,10 +648,28 @@ void XrContext::syncActions() {
|
||||
input_.thumbstickRX = 0.0f;
|
||||
input_.thumbstickRY = 0.0f;
|
||||
for (int i = 0; i < XrInputState::BtnCount; ++i) input_.buttonDown[i] = false;
|
||||
input_.gripDown[0] = false;
|
||||
input_.gripDown[1] = false;
|
||||
if (!actionsReady_) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sessionFocused()) {
|
||||
// Yield controller input and clear all held inputs when the session does not have focus (e.g. Steam dashboard).
|
||||
input_.selectHeld = false;
|
||||
input_.thumbstickX = 0.0f;
|
||||
input_.thumbstickY = 0.0f;
|
||||
input_.thumbstickLX = 0.0f;
|
||||
input_.thumbstickLY = 0.0f;
|
||||
input_.thumbstickRX = 0.0f;
|
||||
input_.thumbstickRY = 0.0f;
|
||||
for (int h = 0; h < 2; ++h) {
|
||||
input_.gripValue[h] = 0.0f;
|
||||
input_.gripFullPressHeld[h] = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
XrActiveActionSet active{actionSet_, XR_NULL_PATH};
|
||||
XrActionsSyncInfo sync{XR_TYPE_ACTIONS_SYNC_INFO};
|
||||
sync.countActiveActionSets = 1;
|
||||
@@ -628,6 +751,84 @@ void XrContext::syncActions() {
|
||||
st.isActive && st.currentState && st.changedSinceLastSync;
|
||||
}
|
||||
}
|
||||
|
||||
// Per-hand grip press: `buttonDown` above aggregates both hands, but the laser
|
||||
// pointer is toggled per hand, so query the squeeze action for each subaction.
|
||||
if (buttonActions_[XrInputState::BtnGrip] != XR_NULL_HANDLE) {
|
||||
for (int h = 0; h < 2; ++h) {
|
||||
XrActionStateGetInfo gi{XR_TYPE_ACTION_STATE_GET_INFO};
|
||||
gi.action = buttonActions_[XrInputState::BtnGrip];
|
||||
gi.subactionPath = handPaths_[h];
|
||||
XrActionStateBoolean st{XR_TYPE_ACTION_STATE_BOOLEAN};
|
||||
if (XR_SUCCEEDED(xrGetActionStateBoolean(session_, &gi, &st))) {
|
||||
input_.gripDown[h] =
|
||||
st.isActive && st.currentState && st.changedSinceLastSync;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Discrete full-press click vs analog soft squeeze
|
||||
for (int h = 0; h < 2; ++h) {
|
||||
float sqVal = 0.0f;
|
||||
if (squeezeValueAction_ != XR_NULL_HANDLE) {
|
||||
XrActionStateGetInfo gi{XR_TYPE_ACTION_STATE_GET_INFO};
|
||||
gi.action = squeezeValueAction_;
|
||||
gi.subactionPath = handPaths_[h];
|
||||
XrActionStateFloat st{XR_TYPE_ACTION_STATE_FLOAT};
|
||||
if (XR_SUCCEEDED(xrGetActionStateFloat(session_, &gi, &st)) && st.isActive) {
|
||||
sqVal = st.currentState;
|
||||
}
|
||||
}
|
||||
input_.gripValue[h] = sqVal;
|
||||
|
||||
bool fullHeld = false;
|
||||
bool fullDown = false;
|
||||
if (gripFullPressAction_ != XR_NULL_HANDLE) {
|
||||
XrActionStateGetInfo gi{XR_TYPE_ACTION_STATE_GET_INFO};
|
||||
gi.action = gripFullPressAction_;
|
||||
gi.subactionPath = handPaths_[h];
|
||||
XrActionStateBoolean st{XR_TYPE_ACTION_STATE_BOOLEAN};
|
||||
if (XR_SUCCEEDED(xrGetActionStateBoolean(session_, &gi, &st)) && st.isActive) {
|
||||
fullHeld = st.currentState;
|
||||
fullDown = st.currentState && st.changedSinceLastSync;
|
||||
}
|
||||
}
|
||||
// Fallback for hardware profiles where squeeze/click is not exposed separately:
|
||||
// Use a strict bottom-out threshold (>= 0.90f to engage, < 0.80f to release)
|
||||
static bool sPrevFull[2] = {false, false};
|
||||
if (!fullHeld) {
|
||||
if (sPrevFull[h]) {
|
||||
if (sqVal >= 0.80f) {
|
||||
fullHeld = true;
|
||||
} else {
|
||||
sPrevFull[h] = false;
|
||||
}
|
||||
} else {
|
||||
if (sqVal >= 0.90f) {
|
||||
fullHeld = true;
|
||||
fullDown = true;
|
||||
sPrevFull[h] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
input_.gripFullPressHeld[h] = fullHeld;
|
||||
input_.gripFullPressDown[h] = fullDown;
|
||||
}
|
||||
}
|
||||
|
||||
void XrContext::triggerHaptic(int hand, float durationSec, float frequencyHz, float amplitude) {
|
||||
if (session_ == XR_NULL_HANDLE || hapticAction_ == XR_NULL_HANDLE) return;
|
||||
if (hand < 0 || hand >= 2 || handPaths_[hand] == XR_NULL_PATH) return;
|
||||
|
||||
XrHapticVibration vib{XR_TYPE_HAPTIC_VIBRATION};
|
||||
vib.duration = static_cast<XrDuration>(durationSec * 1e9f);
|
||||
vib.frequency = frequencyHz;
|
||||
vib.amplitude = std::clamp(amplitude, 0.0f, 1.0f);
|
||||
|
||||
XrHapticActionInfo hai{XR_TYPE_HAPTIC_ACTION_INFO};
|
||||
hai.action = hapticAction_;
|
||||
hai.subactionPath = handPaths_[hand];
|
||||
xrApplyHapticFeedback(session_, &hai, reinterpret_cast<const XrHapticBaseHeader*>(&vib));
|
||||
}
|
||||
|
||||
void XrContext::handleSessionStateChange(const XrEventDataSessionStateChanged& ev,
|
||||
@@ -737,10 +938,36 @@ bool XrContext::renderFrame(const RenderViewFn& renderView) {
|
||||
input_.headForward[2] = -(1.0f - 2.0f * (q.x * q.x + q.y * q.y));
|
||||
}
|
||||
|
||||
input_.stageValid = false;
|
||||
if (stageSpace_ != XR_NULL_HANDLE && input_.headValid) {
|
||||
XrSpaceLocation loc{XR_TYPE_SPACE_LOCATION};
|
||||
if (XR_SUCCEEDED(xrLocateSpace(appSpace_, stageSpace_, frameState.predictedDisplayTime, &loc))) {
|
||||
constexpr XrSpaceLocationFlags need =
|
||||
XR_SPACE_LOCATION_ORIENTATION_VALID_BIT |
|
||||
XR_SPACE_LOCATION_POSITION_VALID_BIT;
|
||||
if ((loc.locationFlags & need) == need) {
|
||||
input_.stageValid = true;
|
||||
input_.appToStagePose = loc.pose;
|
||||
transformToStage(input_.headPos, input_.headForward,
|
||||
input_.headPosStage, input_.headForwardStage);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!input_.stageValid) {
|
||||
// Fallback: Standing stage eye level is ~1.65m above the physical floor
|
||||
input_.headPosStage[0] = input_.headPos[0];
|
||||
input_.headPosStage[1] = input_.headPos[1] + 1.65f;
|
||||
input_.headPosStage[2] = input_.headPos[2];
|
||||
input_.headForwardStage[0] = input_.headForward[0];
|
||||
input_.headForwardStage[1] = input_.headForward[1];
|
||||
input_.headForwardStage[2] = input_.headForward[2];
|
||||
}
|
||||
|
||||
// Locate each controller's aim pose (for the pointer rays and models).
|
||||
for (int h = 0; h < 2; ++h) {
|
||||
input_.controllers[h].active = false;
|
||||
if (aimSpaces_[h] == XR_NULL_HANDLE) {
|
||||
const bool canTrack = sessionFocused() || VrKeyboard::instance().isOpen();
|
||||
if (!canTrack || aimSpaces_[h] == XR_NULL_HANDLE) {
|
||||
continue;
|
||||
}
|
||||
XrSpaceLocation loc{XR_TYPE_SPACE_LOCATION};
|
||||
@@ -768,7 +995,8 @@ bool XrContext::renderFrame(const RenderViewFn& renderView) {
|
||||
// the model is authored around the grip, not the forward-pointing aim).
|
||||
for (int h = 0; h < 2; ++h) {
|
||||
input_.controllers[h].gripActive = false;
|
||||
if (gripSpaces_[h] == XR_NULL_HANDLE) {
|
||||
const bool canTrack = sessionFocused() || VrKeyboard::instance().isOpen();
|
||||
if (!canTrack || gripSpaces_[h] == XR_NULL_HANDLE) {
|
||||
continue;
|
||||
}
|
||||
XrSpaceLocation loc{XR_TYPE_SPACE_LOCATION};
|
||||
@@ -841,4 +1069,31 @@ bool XrContext::renderFrame(const RenderViewFn& renderView) {
|
||||
return true;
|
||||
}
|
||||
|
||||
void XrContext::transformToStage(const float localPos[3], const float localFwd[3],
|
||||
float stagePos[3], float stageFwd[3]) const {
|
||||
const auto& p = input_.appToStagePose.position;
|
||||
const auto& q = input_.appToStagePose.orientation;
|
||||
|
||||
if (localPos && stagePos) {
|
||||
const float vx = localPos[0], vy = localPos[1], vz = localPos[2];
|
||||
const float tw = -q.x * vx - q.y * vy - q.z * vz;
|
||||
const float tx = q.w * vx + q.y * vz - q.z * vy;
|
||||
const float ty = q.w * vy + q.z * vx - q.x * vz;
|
||||
const float tz = q.w * vz + q.x * vy - q.y * vx;
|
||||
stagePos[0] = p.x + (tx * q.w + tw * (-q.x) + ty * (-q.z) - tz * (-q.y));
|
||||
stagePos[1] = p.y + (ty * q.w + tw * (-q.y) + tz * (-q.x) - tx * (-q.z));
|
||||
stagePos[2] = p.z + (tz * q.w + tw * (-q.z) + tx * (-q.y) - ty * (-q.x));
|
||||
}
|
||||
if (localFwd && stageFwd) {
|
||||
const float fx = localFwd[0], fy = localFwd[1], fz = localFwd[2];
|
||||
const float ftw = -q.x * fx - q.y * fy - q.z * fz;
|
||||
const float ftx = q.w * fx + q.y * fz - q.z * fy;
|
||||
const float fty = q.w * fy + q.z * fx - q.x * fz;
|
||||
const float ftz = q.w * fz + q.x * fy - q.y * fx;
|
||||
stageFwd[0] = ftx * q.w + ftw * (-q.x) + fty * (-q.z) - ftz * (-q.y);
|
||||
stageFwd[1] = fty * q.w + ftw * (-q.y) + ftz * (-q.x) - ftx * (-q.z);
|
||||
stageFwd[2] = ftz * q.w + ftw * (-q.z) + ftx * (-q.y) - fty * (-q.x);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace pixelvr
|
||||
@@ -34,6 +34,10 @@ struct XrInputState {
|
||||
bool headValid = false;
|
||||
float headPos[3] = {0.0f, 0.0f, 0.0f};
|
||||
float headForward[3] = {0.0f, 0.0f, -1.0f};
|
||||
bool stageValid = false;
|
||||
XrPosef appToStagePose{{0.0f, 0.0f, 0.0f, 1.0f}, {0.0f, 1.65f, 0.0f}};
|
||||
float headPosStage[3] = {0.0f, 1.65f, 0.0f};
|
||||
float headForwardStage[3] = {0.0f, 0.0f, -1.0f};
|
||||
|
||||
// Tracked controller aim poses (index 0 = left, 1 = right). The pointer ray
|
||||
// is the -Z axis of `orientation` from `position`, in the app reference space.
|
||||
@@ -56,6 +60,17 @@ struct XrInputState {
|
||||
BtnDpadUp, BtnDpadDown, BtnDpadLeft, BtnDpadRight, BtnCount
|
||||
};
|
||||
bool buttonDown[BtnCount] = {};
|
||||
|
||||
// Per-hand grip (squeeze) rising edge: toggles that hand's laser pointer, so
|
||||
// one controller can drive a panel with the stick while the other points.
|
||||
// Index 0 = left, 1 = right.
|
||||
bool gripDown[2] = {false, false};
|
||||
|
||||
// Discrete full-press click per hand (Index / Frame / Touch squeeze click).
|
||||
// Distinct from soft/resting squeeze. Index 0 = left, 1 = right.
|
||||
bool gripFullPressHeld[2] = {false, false};
|
||||
bool gripFullPressDown[2] = {false, false};
|
||||
float gripValue[2] = {0.0f, 0.0f}; // analog soft squeeze value (0.0 to 1.0)
|
||||
};
|
||||
|
||||
// Owns the OpenXR instance, system, session, reference space, and swapchains,
|
||||
@@ -89,12 +104,14 @@ public:
|
||||
// Controller input (OpenXR actions). Call createActions after createSession.
|
||||
bool createActions();
|
||||
const XrInputState& input() const { return input_; }
|
||||
void triggerHaptic(int hand, float durationSec = 0.015f, float frequencyHz = 0.0f, float amplitude = 0.5f);
|
||||
|
||||
// --- Runtime ---------------------------------------------------------
|
||||
// Pumps the event queue and advances the session state machine.
|
||||
// Sets exitRequested when the runtime asks the app to quit.
|
||||
bool pollEvents(bool& exitRequested);
|
||||
bool sessionRunning() const { return sessionRunning_; }
|
||||
bool sessionFocused() const { return sessionState_ == XR_SESSION_STATE_FOCUSED; }
|
||||
|
||||
// Callback invoked once per view when the frame should be rendered.
|
||||
using RenderViewFn = std::function<void(uint32_t viewIndex, uint32_t imageIndex,
|
||||
@@ -109,7 +126,10 @@ public:
|
||||
XrInstance instance() const { return instance_; }
|
||||
XrSession session() const { return session_; }
|
||||
XrSpace appSpace() const { return appSpace_; }
|
||||
XrSpace stageSpace() const { return stageSpace_; }
|
||||
XrTime displayTime() const { return lastDisplayTime_; }
|
||||
void transformToStage(const float localPos[3], const float localFwd[3],
|
||||
float stagePos[3], float stageFwd[3]) const;
|
||||
XrSystemId systemId() const { return systemId_; }
|
||||
bool renderModelEnabled() const { return renderModelEnabled_; }
|
||||
uint32_t viewCount() const { return static_cast<uint32_t>(configViews_.size()); }
|
||||
@@ -132,6 +152,7 @@ private:
|
||||
XrSystemId systemId_ = XR_NULL_SYSTEM_ID;
|
||||
XrSession session_ = XR_NULL_HANDLE;
|
||||
XrSpace appSpace_ = XR_NULL_HANDLE;
|
||||
XrSpace stageSpace_ = XR_NULL_HANDLE;
|
||||
XrTime lastDisplayTime_ = 0;
|
||||
|
||||
XrSessionState sessionState_ = XR_SESSION_STATE_UNKNOWN;
|
||||
@@ -156,6 +177,9 @@ private:
|
||||
XrAction aimPoseAction_ = XR_NULL_HANDLE;
|
||||
XrAction gripPoseAction_ = XR_NULL_HANDLE;
|
||||
XrAction buttonActions_[XrInputState::BtnCount] = {};
|
||||
XrAction gripFullPressAction_ = XR_NULL_HANDLE;
|
||||
XrAction squeezeValueAction_ = XR_NULL_HANDLE;
|
||||
XrAction hapticAction_ = XR_NULL_HANDLE;
|
||||
XrPath handPaths_[2] = {XR_NULL_PATH, XR_NULL_PATH};
|
||||
XrSpace aimSpaces_[2] = {XR_NULL_HANDLE, XR_NULL_HANDLE};
|
||||
XrSpace gripSpaces_[2] = {XR_NULL_HANDLE, XR_NULL_HANDLE};
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
#include "util/ipv4.hpp"
|
||||
#include <cassert>
|
||||
#include <iostream>
|
||||
|
||||
using namespace pixelvr;
|
||||
|
||||
int main() {
|
||||
std::cout << "[TEST] Starting IPv4 sanitization & validation test..." << std::endl;
|
||||
|
||||
// Normal valid IP
|
||||
assert(sanitizeIpv4("192.168.1.1", true) == "192.168.1.1");
|
||||
assert(isValidIpv4("192.168.1.1"));
|
||||
|
||||
// Leading zeros dropped in each section
|
||||
assert(sanitizeIpv4("192.168.01.005", true) == "192.168.1.5");
|
||||
assert(sanitizeIpv4("010.000.001.020", true) == "10.0.1.20");
|
||||
assert(sanitizeIpv4("0.0.0.0", true) == "0.0.0.0");
|
||||
|
||||
// Clamping > 255
|
||||
assert(sanitizeIpv4("192.168.1.300", true) == "192.168.1.255");
|
||||
assert(sanitizeIpv4("999.999.999.999", true) == "255.255.255.255");
|
||||
|
||||
// Live typing with trailing dot
|
||||
assert(sanitizeIpv4("192.168.", false) == "192.168.");
|
||||
assert(sanitizeIpv4("192.168.", true) == "192.168");
|
||||
|
||||
// Non-digit characters discarded
|
||||
assert(sanitizeIpv4("192.abc.168.foo.1.bar.100", true) == "192.168.1.100");
|
||||
assert(sanitizeIpv4(" 10 . 0 . 0 . 1 ", true) == "10.0.0.1");
|
||||
|
||||
// Max 4 sections (extra sections ignored)
|
||||
assert(sanitizeIpv4("192.168.1.1.99.88", true) == "192.168.1.1");
|
||||
|
||||
std::cout << "[ALL TESTS PASSED] IPv4 sanitization is verified!" << std::endl;
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
#include "net/smb_credentials.hpp"
|
||||
#include <cassert>
|
||||
#include <iostream>
|
||||
#include <fstream>
|
||||
#include <filesystem>
|
||||
#include <sys/stat.h>
|
||||
|
||||
using namespace pixelvr;
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
int main() {
|
||||
std::cout << "[TEST] Starting SmbCredentialsManager verification..." << std::endl;
|
||||
auto& mgr = SmbCredentialsManager::instance();
|
||||
|
||||
// 1. Add a protected share
|
||||
SmbShare share1;
|
||||
share1.id = "192.168.1.100/movies";
|
||||
share1.name = "Living Room NAS";
|
||||
share1.host = "192.168.1.100";
|
||||
share1.share = "movies";
|
||||
share1.workgroup = "MYGROUP";
|
||||
share1.username = "nasadmin";
|
||||
share1.password = "SuperSecretPassword123!";
|
||||
share1.anonymous = false;
|
||||
|
||||
mgr.addOrUpdateShare(share1);
|
||||
std::cout << "[PASS] Added protected share." << std::endl;
|
||||
|
||||
// 2. Query credentials
|
||||
std::string user, pass, wg;
|
||||
bool found = mgr.findCredentials("192.168.1.100", "movies", user, pass, wg);
|
||||
assert(found);
|
||||
assert(user == "nasadmin");
|
||||
assert(pass == "SuperSecretPassword123!");
|
||||
assert(wg == "MYGROUP");
|
||||
std::cout << "[PASS] Retrieved exact credentials." << std::endl;
|
||||
|
||||
// 3. Check storage file permissions (must be 0600)
|
||||
const char* home = std::getenv("HOME");
|
||||
std::string configPath = std::string(home ? home : ".") + "/.config/pixelvr/smb_shares.json";
|
||||
struct stat st;
|
||||
assert(stat(configPath.c_str(), &st) == 0);
|
||||
mode_t perm = st.st_mode & 0777;
|
||||
std::cout << "[INFO] File permissions: 0" << std::oct << perm << std::dec << std::endl;
|
||||
assert((perm & 0077) == 0); // No permissions for group or others
|
||||
std::cout << "[PASS] Config file has strict 0600 permissions." << std::endl;
|
||||
|
||||
// 4. Verify password is encrypted on disk
|
||||
std::ifstream cfgFile(configPath);
|
||||
std::string content((std::istreambuf_iterator<char>(cfgFile)), std::istreambuf_iterator<char>());
|
||||
assert(content.find("SuperSecretPassword123!") == std::string::npos);
|
||||
std::cout << "[PASS] Password is encrypted on disk (no plaintext leak)." << std::endl;
|
||||
|
||||
// 5. Test auth file generation
|
||||
std::string authPath = mgr.getAuthFilePath("192.168.1.100", "movies");
|
||||
assert(!authPath.empty());
|
||||
assert(stat(authPath.c_str(), &st) == 0);
|
||||
mode_t authPerm = st.st_mode & 0777;
|
||||
assert((authPerm & 0077) == 0); // Auth file must also be 0600
|
||||
std::cout << "[PASS] Temporary Samba auth file generated with 0600 permissions." << std::endl;
|
||||
|
||||
// 6. Delete share
|
||||
bool deleted = mgr.deleteShare("192.168.1.100", "movies");
|
||||
assert(deleted);
|
||||
found = mgr.findCredentials("192.168.1.100", "movies", user, pass, wg);
|
||||
assert(!found);
|
||||
std::cout << "[PASS] Deleted share successfully." << std::endl;
|
||||
|
||||
std::cout << "[ALL TESTS PASSED] SmbCredentialsManager is verified and secure!" << std::endl;
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
#include "app/vault_manager.hpp"
|
||||
#include <cassert>
|
||||
#include <iostream>
|
||||
#include <fstream>
|
||||
#include <filesystem>
|
||||
|
||||
using namespace pixelvr;
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
int main() {
|
||||
std::cout << "[TEST] Starting VaultManager verification..." << std::endl;
|
||||
auto& vm = VaultManager::instance();
|
||||
|
||||
// Reset to default combo sequence for reproducible testing
|
||||
vm.startRecordingCombo();
|
||||
vm.feedRecordButton(XrInputState::BtnA);
|
||||
vm.feedRecordButton(XrInputState::BtnB);
|
||||
vm.feedRecordButton(XrInputState::BtnB);
|
||||
vm.feedRecordButton(XrInputState::BtnA);
|
||||
vm.feedRecordButton(XrInputState::BtnX);
|
||||
vm.feedRecordButton(XrInputState::BtnY);
|
||||
|
||||
// 1. Initial locked state
|
||||
vm.lock();
|
||||
assert(!vm.isUnlocked());
|
||||
std::cout << "[PASS] Initial state is locked." << std::endl;
|
||||
|
||||
// 2. Discrete grip requirement (soft squeeze / grip=false should not advance)
|
||||
auto now = std::chrono::steady_clock::now();
|
||||
assert(vm.feedInput(XrInputState::BtnA, false, now) == VaultManager::ComboResult::None);
|
||||
assert(vm.feedInput(XrInputState::BtnB, false, now) == VaultManager::ComboResult::None);
|
||||
assert(!vm.isUnlocked());
|
||||
std::cout << "[PASS] Soft squeeze / no grip full press does not trigger combo." << std::endl;
|
||||
|
||||
// 3. Step through default combo: A -> B -> B -> A -> X -> Y
|
||||
assert(vm.feedInput(XrInputState::BtnA, true, now) == VaultManager::ComboResult::StepMatched);
|
||||
assert(vm.feedInput(XrInputState::BtnB, true, now) == VaultManager::ComboResult::StepMatched);
|
||||
assert(vm.feedInput(XrInputState::BtnB, true, now) == VaultManager::ComboResult::StepMatched);
|
||||
assert(vm.feedInput(XrInputState::BtnA, true, now) == VaultManager::ComboResult::StepMatched);
|
||||
assert(vm.feedInput(XrInputState::BtnX, true, now) == VaultManager::ComboResult::StepMatched);
|
||||
assert(vm.feedInput(XrInputState::BtnY, true, now) == VaultManager::ComboResult::Unlocked);
|
||||
assert(vm.isUnlocked());
|
||||
std::cout << "[PASS] Cheat code A-B-B-A-X-Y with grip full press unlocks vault!" << std::endl;
|
||||
|
||||
// Verify vault directories exist and have 0700 permissions
|
||||
assert(fs::exists(vm.vaultDir()));
|
||||
assert(fs::exists(vm.thumbsDir()));
|
||||
auto perms = fs::status(vm.vaultDir()).permissions();
|
||||
assert((perms & fs::perms::others_all) == fs::perms::none);
|
||||
std::cout << "[PASS] Vault directory created with secure 0700 permissions: " << vm.vaultDir() << std::endl;
|
||||
|
||||
// 4. Test re-lock via combo
|
||||
assert(vm.feedInput(XrInputState::BtnA, true, now) == VaultManager::ComboResult::StepMatched);
|
||||
assert(vm.feedInput(XrInputState::BtnB, true, now) == VaultManager::ComboResult::StepMatched);
|
||||
assert(vm.feedInput(XrInputState::BtnB, true, now) == VaultManager::ComboResult::StepMatched);
|
||||
assert(vm.feedInput(XrInputState::BtnA, true, now) == VaultManager::ComboResult::StepMatched);
|
||||
assert(vm.feedInput(XrInputState::BtnX, true, now) == VaultManager::ComboResult::StepMatched);
|
||||
assert(vm.feedInput(XrInputState::BtnY, true, now) == VaultManager::ComboResult::Locked);
|
||||
assert(!vm.isUnlocked());
|
||||
std::cout << "[PASS] Entering combo again locks the vault." << std::endl;
|
||||
|
||||
// 5. Test Headset removal auto-lock with 3-second debounce
|
||||
vm.unlock();
|
||||
assert(vm.isUnlocked());
|
||||
assert(!vm.tick(now, false)); // Momentary flicker (<3s) does NOT lock
|
||||
assert(vm.isUnlocked());
|
||||
assert(vm.tick(now + std::chrono::milliseconds(3100), false)); // >=3s continuous loss locks
|
||||
assert(!vm.isUnlocked());
|
||||
std::cout << "[PASS] Auto-lock on headset removal successfully locks vault after 3s debounce." << std::endl;
|
||||
|
||||
// 6. Test Inactivity timeout (enabled vs disabled)
|
||||
vm.unlock();
|
||||
vm.setAutoLockTimeoutSec(2); // 2 seconds for test
|
||||
vm.recordActivity(now);
|
||||
assert(!vm.tick(now, true)); // immediate tick does not lock
|
||||
assert(vm.isUnlocked());
|
||||
assert(vm.tick(now + std::chrono::seconds(3), true)); // 3s later -> timeout
|
||||
assert(!vm.isUnlocked());
|
||||
std::cout << "[PASS] Inactivity timeout successfully locks vault when enabled." << std::endl;
|
||||
|
||||
// Test Inactivity timeout disabled (default)
|
||||
vm.unlock();
|
||||
vm.setAutoLockTimeoutSec(0); // 0 = disabled
|
||||
vm.recordActivity(now);
|
||||
assert(!vm.tick(now + std::chrono::hours(1), true)); // 1 hour later -> still unlocked because disabled
|
||||
assert(vm.isUnlocked());
|
||||
std::cout << "[PASS] Inactivity timeout = 0 disables timeout auto-lock." << std::endl;
|
||||
|
||||
// 7. Test Move-to-Vault for single file and collision resolution
|
||||
vm.unlock();
|
||||
std::string testSrc = "/tmp/pixelvr_test_clip.mp4";
|
||||
{
|
||||
std::ofstream f(testSrc);
|
||||
f << "dummy video content 12345";
|
||||
}
|
||||
std::string outPath1;
|
||||
std::string err1 = vm.moveFileToVault(testSrc, outPath1);
|
||||
assert(err1.empty());
|
||||
assert(!fs::exists(testSrc));
|
||||
assert(fs::exists(outPath1));
|
||||
std::cout << "[PASS] Move file to vault succeeded: " << outPath1 << std::endl;
|
||||
|
||||
// Collision test: create another file with same name
|
||||
{
|
||||
std::ofstream f(testSrc);
|
||||
f << "dummy video content second version";
|
||||
}
|
||||
std::string outPath2;
|
||||
std::string err2 = vm.moveFileToVault(testSrc, outPath2);
|
||||
assert(err2.empty());
|
||||
assert(!fs::exists(testSrc));
|
||||
assert(fs::exists(outPath2));
|
||||
assert(outPath1 != outPath2);
|
||||
std::cout << "[PASS] Collision resolved properly: " << outPath2 << std::endl;
|
||||
|
||||
// Clean up test files
|
||||
fs::remove(outPath1);
|
||||
fs::remove(outPath2);
|
||||
|
||||
// 8. Test Move-to-Vault for entire directory and recursive permissions
|
||||
std::string testDir = "/tmp/pixelvr_test_folder";
|
||||
fs::create_directories(testDir);
|
||||
{
|
||||
std::ofstream f1(testDir + "/subvideo1.mp4");
|
||||
f1 << "sub video 1 content";
|
||||
std::ofstream f2(testDir + "/subvideo2.mp4");
|
||||
f2 << "sub video 2 content";
|
||||
}
|
||||
std::string outDir1;
|
||||
std::string errDir1 = vm.moveFileToVault(testDir, outDir1);
|
||||
assert(errDir1.empty());
|
||||
assert(!fs::exists(testDir));
|
||||
assert(fs::exists(outDir1));
|
||||
assert(fs::is_directory(outDir1));
|
||||
assert(fs::exists(outDir1 + "/subvideo1.mp4"));
|
||||
assert(fs::exists(outDir1 + "/subvideo2.mp4"));
|
||||
|
||||
// Check directory permissions
|
||||
auto dirPerms = fs::status(outDir1).permissions();
|
||||
assert((dirPerms & fs::perms::others_all) == fs::perms::none);
|
||||
std::cout << "[PASS] Move entire folder to vault succeeded with 0700 permissions: " << outDir1 << std::endl;
|
||||
|
||||
// Folder collision test: recreate folder and move again
|
||||
fs::create_directories(testDir);
|
||||
{
|
||||
std::ofstream f(testDir + "/subvideo3.mp4");
|
||||
f << "sub video 3 content";
|
||||
}
|
||||
std::string outDir2;
|
||||
std::string errDir2 = vm.moveFileToVault(testDir, outDir2);
|
||||
assert(errDir2.empty());
|
||||
assert(!fs::exists(testDir));
|
||||
assert(fs::exists(outDir2));
|
||||
assert(outDir1 != outDir2);
|
||||
assert(fs::exists(outDir2 + "/subvideo3.mp4"));
|
||||
std::cout << "[PASS] Folder collision resolved properly: " << outDir2 << std::endl;
|
||||
|
||||
// Clean up test directories
|
||||
fs::remove_all(outDir1);
|
||||
fs::remove_all(outDir2);
|
||||
|
||||
// 9. Custom combo recording
|
||||
vm.startRecordingCombo();
|
||||
assert(vm.isRecordingCombo());
|
||||
assert(!vm.feedRecordButton(XrInputState::BtnX));
|
||||
assert(!vm.feedRecordButton(XrInputState::BtnX));
|
||||
assert(!vm.feedRecordButton(XrInputState::BtnY));
|
||||
assert(!vm.feedRecordButton(XrInputState::BtnY));
|
||||
assert(!vm.feedRecordButton(XrInputState::BtnA));
|
||||
assert(vm.feedRecordButton(XrInputState::BtnB)); // 6th button finishes
|
||||
assert(!vm.isRecordingCombo());
|
||||
assert(vm.comboString() == "X X Y Y A B");
|
||||
std::cout << "[PASS] Custom combo recording verified: " << vm.comboString() << std::endl;
|
||||
|
||||
// Test new combo works
|
||||
vm.lock();
|
||||
assert(vm.feedInput(XrInputState::BtnX, true, now) == VaultManager::ComboResult::StepMatched);
|
||||
assert(vm.feedInput(XrInputState::BtnX, true, now) == VaultManager::ComboResult::StepMatched);
|
||||
assert(vm.feedInput(XrInputState::BtnY, true, now) == VaultManager::ComboResult::StepMatched);
|
||||
assert(vm.feedInput(XrInputState::BtnY, true, now) == VaultManager::ComboResult::StepMatched);
|
||||
assert(vm.feedInput(XrInputState::BtnA, true, now) == VaultManager::ComboResult::StepMatched);
|
||||
assert(vm.feedInput(XrInputState::BtnB, true, now) == VaultManager::ComboResult::Unlocked);
|
||||
assert(vm.isUnlocked());
|
||||
std::cout << "[PASS] New custom combo successfully unlocked vault!" << std::endl;
|
||||
|
||||
// Restore default combo
|
||||
vm.startRecordingCombo();
|
||||
vm.feedRecordButton(XrInputState::BtnA);
|
||||
vm.feedRecordButton(XrInputState::BtnB);
|
||||
vm.feedRecordButton(XrInputState::BtnB);
|
||||
vm.feedRecordButton(XrInputState::BtnA);
|
||||
vm.feedRecordButton(XrInputState::BtnX);
|
||||
vm.feedRecordButton(XrInputState::BtnY);
|
||||
vm.lock();
|
||||
|
||||
std::cout << "\n*** ALL VAULT MANAGER UNIT TESTS PASSED PERFECTLY! ***" << std::endl;
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
[Desktop Entry]
|
||||
Name=Install Pixel VR Player
|
||||
Comment=Install Pixel VR Player for Valve Steam Frame
|
||||
Exec=bash -c 'SCRIPT_DIR="$(dirname "%k")"; if [ -f "$SCRIPT_DIR/install.sh" ]; then cd "$SCRIPT_DIR" && bash install.sh; else curl -fsSL https://raw.githubusercontent.com/Pixelplanet/Pixel-VR-Player/main/tools/install.sh | bash; fi; if [ $? -eq 0 ]; then command -v kdialog >/dev/null && kdialog --title "Pixel VR Player" --msgbox "Installation complete!\n\nPixel VR Player has been added to your Steam Library.\nYou can now return to Gaming Mode."; else command -v kdialog >/dev/null && kdialog --title "Pixel VR Player" --error "Installation encountered an issue.\nCheck ~/pixelvr-install.log or terminal output."; fi; read -p "Press Enter to close..." dummy'
|
||||
Icon=steam
|
||||
Terminal=true
|
||||
Type=Application
|
||||
Categories=AudioVideo;Player;VR;
|
||||
@@ -0,0 +1,8 @@
|
||||
[Desktop Entry]
|
||||
Name=Update Pixel VR Player
|
||||
Comment=Update Pixel VR Player to the latest version on Valve Steam Frame
|
||||
Exec=bash -c 'SCRIPT_DIR="$(dirname "%k")"; if [ -f "$HOME/PixelVRPlayer/update.sh" ]; then bash "$HOME/PixelVRPlayer/update.sh"; elif [ -f "$SCRIPT_DIR/update.sh" ]; then cd "$SCRIPT_DIR" && bash update.sh; else curl -fsSL https://raw.githubusercontent.com/Pixelplanet/Pixel-VR-Player/main/tools/install.sh | bash -s -- --update; fi; if [ $? -eq 0 ]; then command -v kdialog >/dev/null && kdialog --title "Pixel VR Player" --msgbox "Update complete!\n\nPixel VR Player has been updated to the latest version.\nYou can now return to Gaming Mode."; else command -v kdialog >/dev/null && kdialog --title "Pixel VR Player" --error "Update encountered an issue.\nCheck terminal output or internet connection."; fi; read -p "Press Enter to close..." dummy'
|
||||
Icon=steam
|
||||
Terminal=true
|
||||
Type=Application
|
||||
Categories=AudioVideo;Player;VR;
|
||||