Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18a50d38ab | ||
|
|
c404131924 | ||
|
|
09a5f73393 | ||
|
|
dfc1664806 | ||
|
|
6c0bfd4e4a | ||
|
|
6bfec8bf19 | ||
|
|
1c18c2c328 | ||
|
|
c8e738ef21 | ||
|
|
7e5915e115 | ||
|
|
be5ea10875 | ||
|
|
ba662f848b | ||
|
|
aa896397fe | ||
|
|
7bf367419f | ||
|
|
83cf192d2d | ||
|
|
2a613ebed5 | ||
|
|
8454ab5de3 | ||
|
|
2c74923701 | ||
|
|
22ed9e1ede | ||
|
|
7987fffed4 |
No files matched your search
@@ -0,0 +1,9 @@
|
||||
# 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
|
||||
*.spv binary
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
@@ -42,3 +42,6 @@ Thumbs.db
|
||||
# Local secrets / device config
|
||||
/local.env
|
||||
/secrets/
|
||||
/scratch/
|
||||
/test_vault
|
||||
/*-plan.md
|
||||
@@ -126,6 +126,7 @@ add_library(pixelvr_core STATIC
|
||||
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
|
||||
|
||||
@@ -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,25 +17,55 @@ 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, 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
|
||||
|
||||
One command, from a terminal on the headset (Desktop Mode or over SSH):
|
||||
<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
|
||||
```
|
||||
|
||||
It downloads the latest ARM64 release, verifies its checksum, installs it to
|
||||
`~/PixelVRPlayer`, and adds **Pixel VR Player** to your Steam library as a
|
||||
Non-Steam Game. Restart Steam if it was already running. SteamVR must be
|
||||
installed.
|
||||
*(No root access or disabled read-only filesystem needed; it installs entirely into user space).*
|
||||
|
||||
Launch **Pixel VR Player** from your library, or directly:
|
||||
### 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
|
||||
~/PixelVRPlayer/run.sh # opens the in-headset file browser
|
||||
@@ -48,11 +82,12 @@ Installer options:
|
||||
| `--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`) and cached
|
||||
thumbnails (`~/.cache/pixelvr`) live outside the install directory and are kept.
|
||||
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](../../releases) page, copy it to the headset, extract it and run
|
||||
[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`.
|
||||
|
||||
@@ -86,6 +121,39 @@ 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
|
||||
|
||||
```
|
||||
@@ -195,8 +263,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'
|
||||
|
||||
|
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.
|
||||
@@ -12,17 +12,20 @@ libraries.
|
||||
|
||||
## Just installing a release (no devkit, no build)
|
||||
|
||||
End users do not need any of the steps below. From a terminal on the headset
|
||||
(Desktop Mode or over SSH):
|
||||
End users do not need to build anything or use devkits. Choose whichever option fits your setup:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Pixelplanet/Pixel-VR-Player/main/tools/install.sh | sh
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
That verifies the checksum, installs to `~/PixelVRPlayer`, and registers
|
||||
**Pixel VR Player** as a Non-Steam Game. Relaunch Steam if it was running. Rerun
|
||||
the same command to upgrade; `--uninstall` removes it. The rest of this document
|
||||
covers building and iterating as a developer.
|
||||
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`).
|
||||
|
||||
The rest of this document covers building and deploying as a developer.
|
||||
|
||||
---
|
||||
|
||||
@@ -38,8 +41,11 @@ covers building and iterating as a developer.
|
||||
# 2b. Or stage a clean folder for the GUI Devkit Client:
|
||||
./tools/stage.sh # -> dist/PixelVRPlayer/
|
||||
|
||||
# 2c. Or build the release tarball end users install from:
|
||||
./tools/package-release.sh build-aarch64 pixelvr-linux-arm64.tar.gz v1.3.0
|
||||
# 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
|
||||
```
|
||||
|
||||
---
|
||||
@@ -80,7 +86,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 |
|
||||
|
||||
@@ -88,8 +94,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***.
|
||||
|
||||
---
|
||||
|
||||
@@ -121,7 +126,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)
|
||||
@@ -174,7 +179,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
|
||||
@@ -225,6 +230,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 |
|
Before Width: | Height: | Size: 642 KiB After Width: | Height: | Size: 249 KiB |
|
Before Width: | Height: | Size: 549 KiB After Width: | Height: | Size: 207 KiB |
@@ -17,14 +17,30 @@ vec3 toLinear(vec3 srgb) {
|
||||
}
|
||||
|
||||
void main() {
|
||||
// Signed-distance text: use Euclidean screen-space gradient with a 0.9px
|
||||
// half-width (~1.8px total ramp) and a slight bold bias (0.465) so thin
|
||||
// stems never shimmer or drop out across head micro-movements in VR.
|
||||
// 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 w = max(length(vec2(dFdx(d), dFdy(d))) * 0.92, 0.025);
|
||||
float a = smoothstep(0.465 - w, 0.465 + w, d);
|
||||
// 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);
|
||||
}
|
||||
@@ -20,7 +20,10 @@ void main() {
|
||||
vec2 b = 0.5 * pc.rect.zw - vec2(0.018);
|
||||
vec2 q = max(p - b, 0.0);
|
||||
float dist = length(q) - 0.018;
|
||||
float aa = max(length(vec2(dFdx(dist), dFdy(dist))) * 1.15, 0.0025);
|
||||
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;
|
||||
|
||||
@@ -173,7 +173,10 @@ void main() {
|
||||
d = sdRoundedBox(p, vec2(1.0), 0.0);
|
||||
}
|
||||
|
||||
float aa = max(length(vec2(dFdx(d), dFdy(d))) * 1.15, 0.003);
|
||||
// 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.002) {
|
||||
@@ -183,7 +186,8 @@ void main() {
|
||||
vec4 fillLin = vec4(pow(max(pc.color.rgb, vec3(0.0)), vec3(2.2)), pc.color.a);
|
||||
vec4 finalColor = fillLin;
|
||||
if (hasBorder) {
|
||||
float borderWidth = max(pc.params.z, aa * 1.6);
|
||||
// Crisp, razor-sharp border highlight line matching requested thickness
|
||||
float borderWidth = pc.params.z;
|
||||
float innerD = d + borderWidth;
|
||||
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);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "app/app.hpp"
|
||||
#include "app/vault_manager.hpp"
|
||||
|
||||
#include "util/logging.hpp"
|
||||
|
||||
@@ -6,6 +7,7 @@
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
@@ -71,19 +73,120 @@ bool contains(const std::string& s, const char* token) {
|
||||
return s.find(token) != std::string::npos;
|
||||
}
|
||||
|
||||
// True if `needle` appears not immediately followed by a digit, so a marker like
|
||||
// True if `needle` appears not immediately preceded or followed by a digit, so a marker like
|
||||
// "360" matches but a resolution like "3600" (or "3600p") does not.
|
||||
bool contains_num(const std::string& s, const char* needle) {
|
||||
const std::string n = needle;
|
||||
std::size_t pos = 0;
|
||||
while ((pos = s.find(n, pos)) != std::string::npos) {
|
||||
const bool prevDigit = (pos > 0 && s[pos - 1] >= '0' && s[pos - 1] <= '9');
|
||||
const std::size_t after = pos + n.size();
|
||||
if (after >= s.size() || s[after] < '0' || s[after] > '9') return true;
|
||||
pos = after;
|
||||
const bool nextDigit = (after < s.size() && s[after] >= '0' && s[after] <= '9');
|
||||
if (!prevDigit && !nextDigit) return true;
|
||||
pos = (after > pos ? after : pos + 1);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Returns true if filename or path patterns indicate VR180 projection
|
||||
bool is_vr180_text(const std::string& lower) {
|
||||
if (contains(lower, "vr180") || contains(lower, "180vr") ||
|
||||
contains(lower, "180x180") || contains(lower, "fisheye") ||
|
||||
contains_num(lower, "180")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// VR resolution tokens commonly used for VR180 dual fisheye (vertical resolution or full frame)
|
||||
const bool hasVrRes = contains_num(lower, "3600") || contains(lower, "3600p") ||
|
||||
contains(lower, "-3600") || contains(lower, "_3600") || contains(lower, ".3600") ||
|
||||
contains_num(lower, "2880") || contains(lower, "2880p") ||
|
||||
contains(lower, "-2880") || contains(lower, "_2880") || contains(lower, ".2880") ||
|
||||
contains_num(lower, "2700") || contains(lower, "2700p") ||
|
||||
contains(lower, "-2700") || contains(lower, "_2700") || contains(lower, ".2700") ||
|
||||
contains_num(lower, "3000") || contains(lower, "3000p") ||
|
||||
contains_num(lower, "5400") || contains(lower, "5400p") ||
|
||||
contains_num(lower, "5760") || contains(lower, "5760p") ||
|
||||
contains(lower, "7200x3600") || contains(lower, "5760x2880") ||
|
||||
contains(lower, "5400x2700") || contains(lower, "6000x3000");
|
||||
|
||||
// Well-known VR video studios / naming patterns
|
||||
const bool hasVrStudio = contains(lower, "wvr") || contains(lower, "wankz") ||
|
||||
contains(lower, "czechvr") || contains(lower, "vrhush") ||
|
||||
contains(lower, "vrbangers") || contains(lower, "realjamvr") ||
|
||||
contains(lower, "badoinkvr") || contains(lower, "badoink") ||
|
||||
contains(lower, "milfvr") || contains(lower, "povr") ||
|
||||
contains(lower, "vrcosplay") || contains(lower, "vrsmash") ||
|
||||
contains(lower, "vrconk") || contains(lower, "slr_") ||
|
||||
contains(lower, "slr-") || contains(lower, "_slr_") ||
|
||||
contains(lower, "-slr-") || contains(lower, ".slr.") ||
|
||||
contains(lower, "swallowvr") || contains(lower, "stripzvr") ||
|
||||
contains(lower, "twistysvr") || contains(lower, "vrphoria") ||
|
||||
contains(lower, "kinkvr") || contains(lower, "lustreality") ||
|
||||
contains(lower, "joymiivr") || contains(lower, "naughtyamericavr");
|
||||
|
||||
const bool hasGenericVr = contains(lower, "_vr_") || contains(lower, "-vr-") ||
|
||||
contains(lower, ".vr.") || contains(lower, " vr ");
|
||||
|
||||
// Check if filename explicitly says 360 (which overrides VR studio / VR generic marker)
|
||||
const bool isExplicit360 = contains_num(lower, "360") || contains(lower, "equirect") ||
|
||||
contains(lower, "vr360") || contains(lower, "360vr") ||
|
||||
contains(lower, "360x180");
|
||||
|
||||
if (!isExplicit360 && (hasVrRes || hasVrStudio || (hasGenericVr && (hasVrRes || contains(lower, "3d") || contains(lower, "sbs"))))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
bool is_vr360_text(const std::string& lower) {
|
||||
if (contains(lower, "vr180") || contains(lower, "180vr") ||
|
||||
contains(lower, "180x180") || contains(lower, "fisheye") ||
|
||||
contains_num(lower, "180")) {
|
||||
return false;
|
||||
}
|
||||
if (contains_num(lower, "360") || contains(lower, "equirect") ||
|
||||
contains(lower, "vr360") || contains(lower, "360vr") ||
|
||||
contains(lower, "360x180") || contains(lower, "sphere")) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool is_vr180_media(const std::string& pathOrName, int w, int h) {
|
||||
const std::string lower = to_lower(pathOrName);
|
||||
if (is_vr180_text(lower)) return true;
|
||||
|
||||
// Check dimensions if probed (dual fisheye SBS aspect ~2.0:1)
|
||||
if (w > 0 && h > 0) {
|
||||
const float ar = static_cast<float>(w) / static_cast<float>(h);
|
||||
const bool is360 = is_vr360_text(lower);
|
||||
if (!is360) {
|
||||
// Exact signature VR180 dual fisheye resolutions
|
||||
if ((w == 7200 && h == 3600) || (w == 5760 && h == 2880) ||
|
||||
(w == 5400 && h == 2700) || (w == 6000 && h == 3000) ||
|
||||
(w == 4800 && h == 2400) || (w == 7680 && h == 3840 && (contains(lower, "vr") || contains(lower, "180")))) {
|
||||
return true;
|
||||
}
|
||||
// General 2:1 dual fisheye with vertical resolution >= 2600 (not standard flat)
|
||||
if (h >= 2600 && ar >= 1.90f && ar <= 2.10f) {
|
||||
return true;
|
||||
}
|
||||
// If name has any vr marker or 3d and ar is ~2.0
|
||||
if ((contains(lower, "vr") || contains(lower, "3d")) && ar >= 1.85f && ar <= 2.15f) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool is_vr360_media(const std::string& pathOrName, int w, int h) {
|
||||
const std::string lower = to_lower(pathOrName);
|
||||
if (is_vr180_media(pathOrName, w, h)) return false;
|
||||
return is_vr360_text(lower);
|
||||
}
|
||||
|
||||
// Infers projection and stereo layout from common filename conventions, e.g.
|
||||
// "clip_360_tb.mp4", "movie_180_sbs.mkv", "trailer_vr180.mp4". `stereoFromName`
|
||||
// reports whether the stereo layout was stated explicitly (so the caller can
|
||||
@@ -92,11 +195,9 @@ void detect_format(const std::string& path, VulkanRenderer::ProjectionMode& proj
|
||||
VulkanRenderer::StereoMode& stereo, bool& stereoFromName) {
|
||||
const std::string p = to_lower(path);
|
||||
|
||||
// Check 180 before 360 and ignore digit runs so "vr180.3600p" is 180, not 360
|
||||
// (the resolution "3600" must not match the "360" marker).
|
||||
if (contains(p, "vr180") || contains_num(p, "180")) {
|
||||
if (is_vr180_text(p)) {
|
||||
proj = VulkanRenderer::ProjectionMode::Equirect180;
|
||||
} else if (contains_num(p, "360") || contains(p, "equirect")) {
|
||||
} else if (is_vr360_text(p)) {
|
||||
proj = VulkanRenderer::ProjectionMode::Equirect360;
|
||||
} else {
|
||||
proj = VulkanRenderer::ProjectionMode::Flat;
|
||||
@@ -108,11 +209,16 @@ void detect_format(const std::string& path, VulkanRenderer::ProjectionMode& proj
|
||||
contains(p, "htab") || contains(p, "h-tab")) {
|
||||
stereo = VulkanRenderer::StereoMode::TopBottom;
|
||||
} else if (contains(p, "sbs") || contains(p, "_lr") ||
|
||||
contains(p, "sidebyside") || contains(p, "side-by-side")) {
|
||||
contains(p, "sidebyside") || contains(p, "side-by-side") ||
|
||||
contains(p, "hsbs") || contains(p, "half-sbs")) {
|
||||
stereo = VulkanRenderer::StereoMode::SideBySide; // incl. HSBS / half-SBS
|
||||
} else if (contains(p, "3d")) {
|
||||
// Bare "3D" with no explicit layout: half-SBS dominates movie rips.
|
||||
stereo = VulkanRenderer::StereoMode::SideBySide;
|
||||
} else if (proj == VulkanRenderer::ProjectionMode::Equirect180) {
|
||||
// VR180 almost universally defaults to SideBySide 3D
|
||||
stereo = VulkanRenderer::StereoMode::SideBySide;
|
||||
stereoFromName = false; // allow refining from aspect ratio once frame is opened
|
||||
} else {
|
||||
stereo = VulkanRenderer::StereoMode::Mono;
|
||||
stereoFromName = false; // no explicit layout; refine from aspect ratio
|
||||
@@ -208,6 +314,17 @@ void App::loadMedia(const std::string& path) {
|
||||
// (360 mono 2:1, SBS 4:1, OU 1:1; 180 mono 1:1, SBS 2:1, OU 1:2).
|
||||
const int w = media_.width();
|
||||
const int h = media_.height();
|
||||
|
||||
// If filename did not indicate VR, check if probed dimensions reveal VR180
|
||||
if (proj == VulkanRenderer::ProjectionMode::Flat && h > 0) {
|
||||
if (is_vr180_media(path, w, h)) {
|
||||
proj = VulkanRenderer::ProjectionMode::Equirect180;
|
||||
if (!stereoFromName) {
|
||||
stereo = VulkanRenderer::StereoMode::SideBySide;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!stereoFromName && h > 0) {
|
||||
const float ar = static_cast<float>(w) / static_cast<float>(h);
|
||||
if (proj == VulkanRenderer::ProjectionMode::Equirect360) {
|
||||
@@ -395,6 +512,62 @@ int App::navStepX(float axisX, std::chrono::steady_clock::time_point now) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
void App::performVaultLock() {
|
||||
VaultManager::instance().lock();
|
||||
renderer_.setVaultUnlocked(false);
|
||||
actionMenu_.visible = false;
|
||||
renderer_.setActionMenu({});
|
||||
|
||||
#ifdef PIXELVR_HAVE_MEDIA
|
||||
if (mediaOpen_ && VaultManager::instance().isVaultPath(currentMediaPath_)) {
|
||||
media_.close();
|
||||
mediaOpen_ = false;
|
||||
renderer_.clearVideo();
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef PIXELVR_HAVE_FREETYPE
|
||||
if (browser_.inVault()) {
|
||||
browser_.openHome();
|
||||
}
|
||||
browser_.clearFinished();
|
||||
downloadToast_ = {};
|
||||
downloadToastText_.clear();
|
||||
renderer_.clearCardThumbnails();
|
||||
thumbnailCache_.clearVaultThumbs(VaultManager::instance().vaultDir());
|
||||
#endif
|
||||
|
||||
if (settings_.section() == 4) {
|
||||
settings_.setSection(0);
|
||||
}
|
||||
settings_.refresh();
|
||||
|
||||
PIXELVR_LOG_INFO("Vault locked and sanitized");
|
||||
}
|
||||
|
||||
void App::openActionMenu() {
|
||||
#ifdef PIXELVR_HAVE_FREETYPE
|
||||
const Browser::Entry* fe = browser_.focused();
|
||||
if (fe == nullptr || fe->isBack || browser_.onNetwork()) return;
|
||||
|
||||
actionMenu_.visible = true;
|
||||
actionMenu_.page = 0;
|
||||
actionMenu_.selectedOption = 0;
|
||||
actionMenu_.title = fe->isDir ? "Folder Actions" : "File Actions";
|
||||
actionMenu_.subtitle = fe->label;
|
||||
actionMenu_.options.clear();
|
||||
|
||||
if (VaultManager::instance().isUnlocked() && !browser_.inVault()) {
|
||||
actionMenu_.options.push_back("Move to Vault");
|
||||
}
|
||||
actionMenu_.options.push_back("Move To...");
|
||||
actionMenu_.options.push_back("Rename");
|
||||
actionMenu_.options.push_back("Delete");
|
||||
actionMenu_.options.push_back("Cancel");
|
||||
actionMenu_.renameBuffer.clear();
|
||||
#endif
|
||||
}
|
||||
|
||||
int App::run() {
|
||||
using namespace std::chrono_literals;
|
||||
|
||||
@@ -437,11 +610,29 @@ int App::run() {
|
||||
});
|
||||
|
||||
// Apply controller input that renderFrame synced this frame.
|
||||
const auto nowT = std::chrono::steady_clock::now();
|
||||
XrInputState in = xr_.input();
|
||||
// Route the remappable functions from the user's chosen physical
|
||||
// buttons (press-to-bind). While the settings menu is listening, the
|
||||
// next button press is captured as the binding, not a function.
|
||||
if (settings_.isListening()) {
|
||||
|
||||
// Custom combo recording listener (active while recording new 6-button combo in Vault & Privacy)
|
||||
if (VaultManager::instance().isRecordingCombo()) {
|
||||
for (int b : {XrInputState::BtnA, XrInputState::BtnB, XrInputState::BtnX, XrInputState::BtnY}) {
|
||||
if (in.buttonDown[b]) {
|
||||
const bool finished = VaultManager::instance().feedRecordButton(b);
|
||||
if (finished) {
|
||||
xr_.triggerHaptic(0, 0.050f, 0.0f, 0.8f);
|
||||
xr_.triggerHaptic(1, 0.050f, 0.0f, 0.8f);
|
||||
} else {
|
||||
xr_.triggerHaptic(0, 0.020f, 0.0f, 0.4f);
|
||||
xr_.triggerHaptic(1, 0.020f, 0.0f, 0.4f);
|
||||
}
|
||||
settings_.refresh();
|
||||
break;
|
||||
}
|
||||
}
|
||||
in.togglePlay = false;
|
||||
in.browserToggle = false;
|
||||
in.recenter = false;
|
||||
} else if (settings_.isListening()) {
|
||||
for (int b = 0; b < XrInputState::BtnCount; ++b) {
|
||||
if (in.buttonDown[b]) {
|
||||
settings_.assignListening(b);
|
||||
@@ -456,25 +647,84 @@ int App::run() {
|
||||
in.browserToggle = in.buttonDown[settings_.bindMenu()];
|
||||
in.recenter = in.buttonDown[settings_.bindRecenter()];
|
||||
}
|
||||
// X / Y are dedicated download buttons: they download the entry
|
||||
// highlighted in the file browser. Keeping download on its own
|
||||
// physical button means the trigger never has to double as a pointer
|
||||
// click on the control bar. Suppressed while binding/opening settings.
|
||||
|
||||
// Discrete grip full-press cheat code detection:
|
||||
// Holding full press of either grip button (bottomed out) and entering the button sequence.
|
||||
const bool gripFullHeld = in.gripFullPressHeld[0] || in.gripFullPressHeld[1];
|
||||
static bool sPrevGripFullHeld = false;
|
||||
if (gripFullHeld != sPrevGripFullHeld) {
|
||||
sPrevGripFullHeld = gripFullHeld;
|
||||
PIXELVR_LOG_INFO("Grip full press state changed: %s (L val: %.2f, R val: %.2f)",
|
||||
gripFullHeld ? "HELD" : "RELEASED",
|
||||
in.gripValue[0], in.gripValue[1]);
|
||||
}
|
||||
|
||||
if (gripFullHeld && !settings_.isListening() && !VaultManager::instance().isRecordingCombo()) {
|
||||
const int h = in.gripFullPressHeld[1] ? 1 : 0;
|
||||
for (int b : {XrInputState::BtnA, XrInputState::BtnB, XrInputState::BtnX, XrInputState::BtnY}) {
|
||||
if (in.buttonDown[b]) {
|
||||
PIXELVR_LOG_INFO("Combo input button press: %s (hand %s)",
|
||||
(b == XrInputState::BtnA) ? "A" :
|
||||
(b == XrInputState::BtnB) ? "B" :
|
||||
(b == XrInputState::BtnX) ? "X" : "Y",
|
||||
h == 0 ? "left" : "right");
|
||||
auto res = VaultManager::instance().feedInput(b, true, nowT);
|
||||
if (res == VaultManager::ComboResult::StepMatched) {
|
||||
PIXELVR_LOG_INFO("Vault combo step matched");
|
||||
xr_.triggerHaptic(h, 0.015f, 0.0f, 0.4f);
|
||||
} else if (res == VaultManager::ComboResult::Unlocked) {
|
||||
PIXELVR_LOG_INFO("Vault combo SUCCESS: UNLOCKED");
|
||||
xr_.triggerHaptic(h, 0.060f, 0.0f, 0.9f);
|
||||
renderer_.setVaultUnlocked(true);
|
||||
if (settings_.isOpen()) {
|
||||
settings_.refresh();
|
||||
} else {
|
||||
downloadsWindow_ = false;
|
||||
settings_.close();
|
||||
browser_.openVault();
|
||||
}
|
||||
} else if (res == VaultManager::ComboResult::Locked) {
|
||||
PIXELVR_LOG_INFO("Vault combo: RELOCKED");
|
||||
xr_.triggerHaptic(h, 0.060f, 0.0f, 0.7f);
|
||||
performVaultLock();
|
||||
} else {
|
||||
PIXELVR_LOG_INFO("Vault combo reset / mismatch");
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Suppress normal button functions while grip is bottomed out
|
||||
in.togglePlay = false;
|
||||
in.browserToggle = false;
|
||||
in.recenter = false;
|
||||
}
|
||||
|
||||
// X is the dedicated download button: it downloads the highlighted SMB entry.
|
||||
// Y (or configured move button) is the dedicated File Actions Submenu button.
|
||||
const bool downloadPressed =
|
||||
!settings_.isListening() && !settings_.isOpen() &&
|
||||
(in.buttonDown[XrInputState::BtnX] ||
|
||||
in.buttonDown[XrInputState::BtnY]);
|
||||
// Grip toggles that hand's laser pointer (SteamVR behaviour), so the
|
||||
// stick / d-pad can drive a panel without the beam fighting it, and so
|
||||
// the user picks which controller is the pointing hand.
|
||||
if (!settings_.isListening()) {
|
||||
!settings_.isListening() && !settings_.isOpen() && !gripFullHeld &&
|
||||
!VaultManager::instance().isRecordingCombo() &&
|
||||
in.buttonDown[XrInputState::BtnX];
|
||||
|
||||
const int configuredMoveBtn = VaultManager::instance().moveButton();
|
||||
const bool actionBtnPressed =
|
||||
!settings_.isListening() && !settings_.isOpen() && !gripFullHeld &&
|
||||
!VaultManager::instance().isRecordingCombo() &&
|
||||
(in.buttonDown[XrInputState::BtnY] ||
|
||||
(configuredMoveBtn >= 0 && configuredMoveBtn < XrInputState::BtnCount &&
|
||||
in.buttonDown[configuredMoveBtn]));
|
||||
|
||||
// Grip full-press click: activate laser pointer on this hand.
|
||||
// Exactly one controller has an active laser pointer at any time.
|
||||
if (!settings_.isListening() && !VaultManager::instance().isRecordingCombo()) {
|
||||
for (int h = 0; h < 2; ++h) {
|
||||
if (in.gripDown[h]) {
|
||||
const bool on = !renderer_.laserOn(h);
|
||||
renderer_.setLaserOn(h, on);
|
||||
PIXELVR_LOG_INFO("laser [%s]: %s",
|
||||
h == 0 ? "left" : "right",
|
||||
on ? "on" : "off");
|
||||
if (in.gripFullPressDown[h]) {
|
||||
if (!renderer_.laserOn(h)) {
|
||||
renderer_.setLaserOn(h, true);
|
||||
xr_.triggerHaptic(h, 0.035f, 0.0f, 0.6f);
|
||||
PIXELVR_LOG_INFO("Laser switched to %s controller",
|
||||
h == 0 ? "left" : "right");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -482,32 +732,37 @@ int App::run() {
|
||||
bool browserOpen = false;
|
||||
bool settingsOpen = false;
|
||||
|
||||
// Fetch the runtime-provided controller model once a controller is
|
||||
// tracked (one-shot; subsequent calls return immediately).
|
||||
// Fetch the runtime-provided controller models once a controller is
|
||||
// tracked (for left 0 and right 1 hands).
|
||||
if (in.controllers[0].active || in.controllers[1].active) {
|
||||
if (renderModels_.poll() && !controllerModelLoaded_) {
|
||||
controllerModelLoaded_ =
|
||||
renderer_.loadControllerModel(renderModels_.gltf());
|
||||
renderModels_.poll();
|
||||
for (int h = 0; h < 2; ++h) {
|
||||
if (!controllerModelLoaded_[h] && renderModels_.assetReady(h)) {
|
||||
controllerModelLoaded_[h] =
|
||||
renderer_.loadControllerModel(h, renderModels_.gltf(h));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drive the model's per-node animation (button presses, trigger pull,
|
||||
// Drive each model's per-node animation (button presses, trigger pull,
|
||||
// thumbstick tilt) from the runtime's render-model state.
|
||||
if (controllerModelLoaded_) {
|
||||
std::vector<RenderModelSystem::NodeState> ns;
|
||||
if (renderModels_.nodeStates(xr_.displayTime(), ns)) {
|
||||
const auto& names = renderModels_.nodeNames();
|
||||
std::vector<VulkanRenderer::NodeStateVis> vis;
|
||||
vis.reserve(ns.size());
|
||||
for (std::size_t i = 0; i < ns.size(); ++i) {
|
||||
VulkanRenderer::NodeStateVis v;
|
||||
v.name = (i < names.size()) ? names[i] : std::string();
|
||||
for (int k = 0; k < 3; ++k) v.pos[k] = ns[i].pos[k];
|
||||
for (int k = 0; k < 4; ++k) v.quat[k] = ns[i].quat[k];
|
||||
v.visible = ns[i].visible;
|
||||
vis.push_back(v);
|
||||
for (int h = 0; h < 2; ++h) {
|
||||
if (controllerModelLoaded_[h]) {
|
||||
std::vector<RenderModelSystem::NodeState> ns;
|
||||
if (renderModels_.nodeStates(h, xr_.displayTime(), ns)) {
|
||||
const auto& names = renderModels_.nodeNames(h);
|
||||
std::vector<VulkanRenderer::NodeStateVis> vis;
|
||||
vis.reserve(ns.size());
|
||||
for (std::size_t i = 0; i < ns.size(); ++i) {
|
||||
VulkanRenderer::NodeStateVis v;
|
||||
v.name = (i < names.size()) ? names[i] : std::string();
|
||||
for (int k = 0; k < 3; ++k) v.pos[k] = ns[i].pos[k];
|
||||
for (int k = 0; k < 4; ++k) v.quat[k] = ns[i].quat[k];
|
||||
v.visible = ns[i].visible;
|
||||
vis.push_back(v);
|
||||
}
|
||||
renderer_.setControllerNodeStates(h, vis);
|
||||
}
|
||||
renderer_.setControllerNodeStates(vis);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -527,7 +782,6 @@ int App::run() {
|
||||
// Controller activity: moving a controller past a small threshold (or
|
||||
// any button/stick input) keeps the laser, models and controls shown;
|
||||
// after a few seconds of stillness they fade away.
|
||||
const auto nowT = std::chrono::steady_clock::now();
|
||||
bool activity = false;
|
||||
for (int h = 0; h < 2; ++h) {
|
||||
const auto& c = in.controllers[h];
|
||||
@@ -560,13 +814,21 @@ int App::run() {
|
||||
std::fabs(in.thumbstickRX) > 0.15f) {
|
||||
activity = true;
|
||||
}
|
||||
if (activity) lastControllerActivity_ = nowT;
|
||||
if (activity) {
|
||||
lastControllerActivity_ = nowT;
|
||||
VaultManager::instance().recordActivity(nowT);
|
||||
}
|
||||
if (VaultManager::instance().tick(nowT, in.headValid && xr_.sessionFocused())) {
|
||||
performVaultLock();
|
||||
}
|
||||
const bool controllersEngaged =
|
||||
(nowT - lastControllerActivity_) < std::chrono::milliseconds(3000);
|
||||
|
||||
#ifdef PIXELVR_HAVE_FREETYPE
|
||||
if (in.browserToggle) {
|
||||
if (downloadsWindow_) {
|
||||
if (actionMenu_.visible) {
|
||||
actionMenu_.visible = false;
|
||||
} else if (downloadsWindow_) {
|
||||
downloadsWindow_ = false;
|
||||
} else if (settings_.isOpen()) {
|
||||
settings_.close();
|
||||
@@ -602,22 +864,23 @@ int App::run() {
|
||||
} else {
|
||||
dlWasActive_ = false;
|
||||
}
|
||||
const bool dlRecent = browser_.downloadActive() || nowT < downloadToast_;
|
||||
const bool dlRecent = browser_.downloadActive() || browser_.hasQueuedDownloads() || nowT < downloadToast_;
|
||||
bool dlFailed = false;
|
||||
for (const auto& j : browser_.downloadJobs()) {
|
||||
if (j.state == 2) dlFailed = true;
|
||||
}
|
||||
// No head-locked download text (it followed the view); progress lives
|
||||
// in the Downloads window and the control-bar badge.
|
||||
const bool dlAnyActive = browser_.downloadActive() || browser_.hasQueuedDownloads();
|
||||
renderer_.setDownloadStatus(false, "");
|
||||
renderer_.setDownloadsBadge(browser_.downloadActive(),
|
||||
!browser_.downloadActive() &&
|
||||
renderer_.setDownloadsBadge(dlAnyActive,
|
||||
!dlAnyActive &&
|
||||
browser_.hasDownloads() && dlRecent,
|
||||
!browser_.downloadActive() && dlFailed);
|
||||
//Downloads window: job list plus action buttons at the bottom.
|
||||
!dlAnyActive && dlFailed);
|
||||
// Downloads window: job list plus action buttons at the bottom.
|
||||
std::vector<std::string> drows;
|
||||
std::vector<std::string> dbtns;
|
||||
if (browser_.downloadActive()) {
|
||||
if (browser_.downloadActive() || browser_.hasQueuedDownloads()) {
|
||||
dbtns.push_back(browser_.downloadPaused() ? "Resume All" : "Pause All");
|
||||
dbtns.push_back("Cancel Active");
|
||||
}
|
||||
@@ -628,7 +891,7 @@ int App::run() {
|
||||
const auto& jobs = browser_.downloadJobs();
|
||||
for (std::size_t i = 0; i < jobs.size(); ++i) {
|
||||
const auto& j = jobs[i];
|
||||
if (j.state == 0) ++activeDlCount;
|
||||
if (j.state == 0 || j.state == 4) ++activeDlCount;
|
||||
std::string nm = j.name;
|
||||
if (nm.size() > 30) nm = nm.substr(0, 29) + "~";
|
||||
char buf[192];
|
||||
@@ -674,6 +937,9 @@ int App::run() {
|
||||
} else if (j.state == 3) {
|
||||
std::snprintf(buf, sizeof(buf), "%s stopped", nm.c_str());
|
||||
di.detail = "Paused / Stopped";
|
||||
} else if (j.state == 4) {
|
||||
std::snprintf(buf, sizeof(buf), "%s queued", nm.c_str());
|
||||
di.detail = "Queued in download queue (click to cancel)";
|
||||
} else {
|
||||
std::snprintf(buf, sizeof(buf), "%s failed", nm.c_str());
|
||||
di.detail = "Transfer Failed";
|
||||
@@ -760,15 +1026,41 @@ int App::run() {
|
||||
|
||||
auto classifyMediaBadge = [&](const std::string& pathOrName, int w, int h) -> std::string {
|
||||
const std::string lower = to_lower(pathOrName);
|
||||
const bool is180 = contains(lower, "vr180") || contains_num(lower, "180");
|
||||
const bool is360 = !is180 && (contains_num(lower, "360") || contains(lower, "equirect"));
|
||||
const bool is180 = is_vr180_media(pathOrName, w, h);
|
||||
const bool is360 = !is180 && is_vr360_media(pathOrName, w, h);
|
||||
const bool isTb = contains(lower, "_tb") || contains(lower, "-tb") ||
|
||||
contains(lower, "_ou") || contains(lower, "overunder") ||
|
||||
contains(lower, "htab");
|
||||
const bool isSbs = contains(lower, "sbs") || contains(lower, "_lr") ||
|
||||
contains(lower, "sidebyside") || contains(lower, "3d");
|
||||
if (is180) return (isSbs || isTb) ? "VR180 3D" : "VR180";
|
||||
if (is360) return (isSbs || isTb) ? "VR360 3D" : "VR360";
|
||||
|
||||
if (is180) {
|
||||
if (w >= 7000 || h >= 3500 || contains(lower, "8k") || contains(lower, "3600") || contains(lower, "4320")) {
|
||||
return "VR180 8K";
|
||||
}
|
||||
if (w >= 5000 || h >= 2500 || contains(lower, "6k") || contains(lower, "5k") ||
|
||||
contains(lower, "2880") || contains(lower, "2700") || contains(lower, "5400") || contains(lower, "5760")) {
|
||||
return "VR180 6K";
|
||||
}
|
||||
if (w >= 3600 || h >= 1800 || contains(lower, "4k") || contains(lower, "2160") || contains(lower, "1920")) {
|
||||
return "VR180 4K";
|
||||
}
|
||||
return (isSbs || isTb) ? "VR180 3D" : "VR180";
|
||||
}
|
||||
|
||||
if (is360) {
|
||||
if (w >= 7000 || h >= 3500 || contains(lower, "8k")) {
|
||||
return "VR360 8K";
|
||||
}
|
||||
if (w >= 5000 || h >= 2500 || contains(lower, "6k")) {
|
||||
return "VR360 6K";
|
||||
}
|
||||
if (w >= 3600 || h >= 1800 || contains(lower, "4k")) {
|
||||
return "VR360 4K";
|
||||
}
|
||||
return (isSbs || isTb) ? "VR360 3D" : "VR360";
|
||||
}
|
||||
|
||||
if (isTb) return "3D TB";
|
||||
if (isSbs) return "3D SBS";
|
||||
|
||||
@@ -844,11 +1136,16 @@ int App::run() {
|
||||
items.push_back(std::move(item));
|
||||
}
|
||||
|
||||
renderer_.setBrowserNetwork(browser_.onNetwork());
|
||||
std::string status = browser_.status();
|
||||
if (browser_.onNetwork() && !browser_.isBusy()) {
|
||||
if (status.empty()) {
|
||||
status = "A/Trigger: Stream \xB7 X: Download to Local";
|
||||
}
|
||||
} else if (!browser_.onNetwork() && !browser_.isBusy()) {
|
||||
if (status.empty()) {
|
||||
status = "A/Trigger: Play \xB7 Y: Actions";
|
||||
}
|
||||
}
|
||||
renderer_.setBrowserItems(true, browser_.title(), items,
|
||||
browser_.selected(), status);
|
||||
@@ -874,6 +1171,7 @@ int App::run() {
|
||||
} else {
|
||||
renderer_.setBrowserItems(false, "", {}, 0, "");
|
||||
}
|
||||
renderer_.setActionMenu(actionMenu_);
|
||||
#endif
|
||||
|
||||
// Settings panel overlay
|
||||
@@ -899,43 +1197,28 @@ int App::run() {
|
||||
// Feed controller poses to the renderer (models, pointer ray, cursor);
|
||||
// it raycasts the pointer against UI panels and reports hit rows/buttons.
|
||||
{
|
||||
// Align the model to the runtime render-model space (where the
|
||||
// system draws the real controller). Locate it once per frame and
|
||||
// derive a local grip-space correction applied to both hands.
|
||||
{
|
||||
// Align each controller model to its runtime render-model space
|
||||
// (where the system draws the real controller).
|
||||
for (int h = 0; h < 2; ++h) {
|
||||
if (!in.controllers[h].gripActive) continue;
|
||||
float mPos[3], mQuat[4];
|
||||
if (renderModels_.locate(xr_.appSpace(), xr_.displayTime(),
|
||||
mPos, mQuat)) {
|
||||
int owner = -1;
|
||||
float best = 1e30f;
|
||||
for (int h = 0; h < 2; ++h) {
|
||||
if (!in.controllers[h].gripActive) continue;
|
||||
const float* g = in.controllers[h].gripPosition;
|
||||
const float dx = g[0] - mPos[0], dy = g[1] - mPos[1],
|
||||
dz = g[2] - mPos[2];
|
||||
const float d = dx * dx + dy * dy + dz * dz;
|
||||
if (d < best) { best = d; owner = h; }
|
||||
}
|
||||
if (owner >= 0) {
|
||||
// correction = inverse(gripOwner) then modelPose.
|
||||
const float* gq = in.controllers[owner].gripOrientation;
|
||||
const float* gt = in.controllers[owner].gripPosition;
|
||||
const float giq[4] = {-gq[0], -gq[1], -gq[2], gq[3]};
|
||||
float git[3];
|
||||
quatRotate(giq, gt, git);
|
||||
git[0] = -git[0]; git[1] = -git[1]; git[2] = -git[2];
|
||||
composePose(git, giq, mPos, mQuat, modelCorrPos_,
|
||||
modelCorrQuat_);
|
||||
modelCorrValid_ = true;
|
||||
}
|
||||
if (renderModels_.locate(h, xr_.appSpace(), xr_.displayTime(), mPos, mQuat)) {
|
||||
const float* gq = in.controllers[h].gripOrientation;
|
||||
const float* gt = in.controllers[h].gripPosition;
|
||||
const float giq[4] = {-gq[0], -gq[1], -gq[2], gq[3]};
|
||||
float git[3];
|
||||
quatRotate(giq, gt, git);
|
||||
git[0] = -git[0]; git[1] = -git[1]; git[2] = -git[2];
|
||||
composePose(git, giq, mPos, mQuat, modelCorrPos_[h], modelCorrQuat_[h]);
|
||||
modelCorrValid_[h] = true;
|
||||
}
|
||||
}
|
||||
const bool uiPanelActive = browserOpen || settingsOpen ||
|
||||
downloadsWindow_ || !pendingDl_.empty();
|
||||
downloadsWindow_ || !pendingDl_.empty() || actionMenu_.visible;
|
||||
// The Transport HUD is only visible during playback when the
|
||||
// Unified Spatial Hub and modals are closed.
|
||||
const bool barBlocked = browserOpen || settingsOpen || downloadsWindow_ ||
|
||||
!pendingDl_.empty();
|
||||
!pendingDl_.empty() || actionMenu_.visible;
|
||||
// Controls (buttons + timeline) show only while a controller is
|
||||
// actively moving; they fade after a few seconds of stillness.
|
||||
renderer_.setControlBar(!barBlocked && controllersEngaged &&
|
||||
@@ -955,9 +1238,9 @@ int App::run() {
|
||||
for (int k = 0; k < 4; ++k) v.orientation[k] = c.orientation[k];
|
||||
v.gripActive = c.gripActive;
|
||||
float gp[3], gq[4];
|
||||
if (c.gripActive && modelCorrValid_) {
|
||||
if (c.gripActive && modelCorrValid_[h]) {
|
||||
composePose(c.gripPosition, c.gripOrientation,
|
||||
modelCorrPos_, modelCorrQuat_, gp, gq);
|
||||
modelCorrPos_[h], modelCorrQuat_[h], gp, gq);
|
||||
} else {
|
||||
for (int k = 0; k < 3; ++k) gp[k] = c.gripPosition[k];
|
||||
for (int k = 0; k < 4; ++k) gq[k] = c.gripOrientation[k];
|
||||
@@ -990,7 +1273,8 @@ int App::run() {
|
||||
|
||||
#ifdef PIXELVR_HAVE_FREETYPE
|
||||
// Unified Spatial Hub left navigation rail clicks (shared across Files, Downloads, Settings)
|
||||
const bool hubActive = (browserOpen || settingsOpen || downloadsWindow_) && pendingDl_.empty();
|
||||
const bool hubActive = (browserOpen || settingsOpen || downloadsWindow_) &&
|
||||
pendingDl_.empty() && !actionMenu_.visible;
|
||||
const int sbHover = renderer_.browserSidebarHovered();
|
||||
if (hubActive && (in.togglePlay || in.select) && sbHover >= 0) {
|
||||
if (sbHover == 0) {
|
||||
@@ -1019,6 +1303,22 @@ int App::run() {
|
||||
else if (sub == 2) browser_.openNetwork();
|
||||
else if (sub == 3) browser_.openDownloads();
|
||||
}
|
||||
} else if (sbHover == 7) {
|
||||
// Subtab 5: Vault (Private) in Files or Vault & Privacy in Settings
|
||||
if (VaultManager::instance().isUnlocked()) {
|
||||
if (settings_.isOpen()) {
|
||||
settings_.setSection(4);
|
||||
} else if (browser_.isOpen()) {
|
||||
browser_.openVault();
|
||||
}
|
||||
}
|
||||
} else if (sbHover == 8) {
|
||||
// Lock Vault button
|
||||
if (VaultManager::instance().isUnlocked()) {
|
||||
xr_.triggerHaptic(0, 0.050f, 0.0f, 0.7f);
|
||||
xr_.triggerHaptic(1, 0.050f, 0.0f, 0.7f);
|
||||
performVaultLock();
|
||||
}
|
||||
}
|
||||
interacted = true;
|
||||
}
|
||||
@@ -1027,7 +1327,7 @@ int App::run() {
|
||||
if (dlWin && (in.select || in.togglePlay) && sbHover < 0) {
|
||||
const int bi = renderer_.downloadsButtonHovered();
|
||||
if (bi >= 0) {
|
||||
if (browser_.downloadActive()) {
|
||||
if (browser_.downloadActive() || browser_.hasQueuedDownloads()) {
|
||||
if (bi == 0) browser_.togglePause();
|
||||
else if (bi == 1) browser_.stopDownload();
|
||||
else if (bi == 2) browser_.clearFinished();
|
||||
@@ -1038,28 +1338,183 @@ int App::run() {
|
||||
} else {
|
||||
const int hov = renderer_.downloadsHovered();
|
||||
const auto& jobs = browser_.downloadJobs();
|
||||
if (hov >= 0 && hov < static_cast<int>(jobs.size()) &&
|
||||
jobs[static_cast<std::size_t>(hov)].state == 1 &&
|
||||
!jobs[static_cast<std::size_t>(hov)].localPath.empty()) {
|
||||
if (hov >= 0 && hov < static_cast<int>(jobs.size())) {
|
||||
const auto& j = jobs[static_cast<std::size_t>(hov)];
|
||||
if (j.isDir) {
|
||||
browser_.openPath(j.localPath);
|
||||
downloadsWindow_ = false;
|
||||
} else {
|
||||
if (j.state == 1 && !j.localPath.empty()) {
|
||||
if (j.isDir) {
|
||||
browser_.openPath(j.localPath);
|
||||
downloadsWindow_ = false;
|
||||
} else {
|
||||
#ifdef PIXELVR_HAVE_MEDIA
|
||||
loadMedia(j.localPath);
|
||||
downloadsWindow_ = false;
|
||||
browser_.setOpen(false);
|
||||
loadMedia(j.localPath);
|
||||
downloadsWindow_ = false;
|
||||
browser_.setOpen(false);
|
||||
#endif
|
||||
}
|
||||
interacted = true;
|
||||
} else if (j.state == 4) {
|
||||
browser_.cancelQueued(j.uri);
|
||||
xr_.triggerHaptic(0, 0.020f, 0.0f, 0.4f);
|
||||
xr_.triggerHaptic(1, 0.020f, 0.0f, 0.4f);
|
||||
interacted = true;
|
||||
}
|
||||
interacted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Files view interaction inside Unified Spatial Hub
|
||||
if (browser_.isOpen() && !browser_.isScanning() && !downloadsWindow_ &&
|
||||
!settings_.isOpen() && pendingDl_.empty()) {
|
||||
// File Action Submenu or Files view interaction inside Unified Spatial Hub
|
||||
if (actionMenu_.visible) {
|
||||
if (!browser_.isOpen() || downloadsWindow_ || settings_.isOpen() || !pendingDl_.empty()) {
|
||||
actionMenu_.visible = false;
|
||||
} else {
|
||||
const Browser::Entry* fe = browser_.focused();
|
||||
|
||||
// Button Y (or configured move button) toggles/cancels
|
||||
if (actionBtnPressed) {
|
||||
actionMenu_.visible = false;
|
||||
} else if (actionMenu_.page == 0 || actionMenu_.page == 1) {
|
||||
const int dy = navStep(in.thumbstickY, nowT);
|
||||
if (dy != 0) {
|
||||
actionMenu_.selectedOption += dy;
|
||||
const int maxOpt = static_cast<int>(actionMenu_.options.size()) - 1;
|
||||
if (actionMenu_.selectedOption < 0) actionMenu_.selectedOption = 0;
|
||||
if (actionMenu_.selectedOption > maxOpt) actionMenu_.selectedOption = maxOpt;
|
||||
}
|
||||
const int hov = renderer_.actionMenuBtnHovered();
|
||||
if (hov >= 0) actionMenu_.selectedOption = hov;
|
||||
|
||||
if (in.select || in.togglePlay) {
|
||||
int chosen = (hov >= 0) ? hov : actionMenu_.selectedOption;
|
||||
if (chosen >= 0 && chosen < static_cast<int>(actionMenu_.options.size())) {
|
||||
const std::string opt = actionMenu_.options[chosen];
|
||||
if (actionMenu_.page == 0) {
|
||||
if (opt == "Move to Vault") {
|
||||
std::string newPath;
|
||||
if (browser_.moveFocusedToVault(newPath)) {
|
||||
xr_.triggerHaptic(0, 0.050f, 0.0f, 0.7f);
|
||||
xr_.triggerHaptic(1, 0.050f, 0.0f, 0.7f);
|
||||
}
|
||||
actionMenu_.visible = false;
|
||||
} else if (opt == "Move To...") {
|
||||
actionMenu_.page = 1;
|
||||
actionMenu_.title = "Move Destination";
|
||||
actionMenu_.subtitle = fe ? fe->label : "";
|
||||
actionMenu_.options.clear();
|
||||
actionMenu_.options.push_back("Videos (~/Videos)");
|
||||
actionMenu_.options.push_back("Downloads (~/Downloads)");
|
||||
actionMenu_.options.push_back("Home (~/)");
|
||||
if (!browser_.parentDir().empty()) {
|
||||
actionMenu_.options.push_back("Parent Folder (..)");
|
||||
}
|
||||
actionMenu_.options.push_back("Cancel");
|
||||
actionMenu_.selectedOption = 0;
|
||||
} else if (opt == "Rename") {
|
||||
actionMenu_.page = 3;
|
||||
actionMenu_.title = (fe && fe->isDir) ? "Rename Folder" : "Rename File";
|
||||
actionMenu_.subtitle = fe ? fe->label : "";
|
||||
actionMenu_.renameBuffer = fe ? fe->label : "";
|
||||
actionMenu_.selectedOption = -1;
|
||||
} else if (opt == "Delete") {
|
||||
actionMenu_.page = 2;
|
||||
actionMenu_.title = (fe && fe->isDir) ? "Delete Folder?" : "Delete File?";
|
||||
actionMenu_.subtitle = "Permanently delete \"" + (fe ? fe->label : "") + "\"" +
|
||||
((fe && fe->isDir) ? " and all contents?" : "?");
|
||||
actionMenu_.options.clear();
|
||||
actionMenu_.options.push_back("Yes, Delete");
|
||||
actionMenu_.options.push_back("Cancel");
|
||||
actionMenu_.selectedOption = 1; // Default to Cancel for safety
|
||||
} else if (opt == "Cancel") {
|
||||
actionMenu_.visible = false;
|
||||
}
|
||||
} else if (actionMenu_.page == 1) {
|
||||
if (opt == "Cancel") {
|
||||
actionMenu_.visible = false;
|
||||
} else {
|
||||
std::string targetDir;
|
||||
if (opt == "Videos (~/Videos)") targetDir = browser_.videosDir();
|
||||
else if (opt == "Downloads (~/Downloads)") targetDir = browser_.downloadsDir();
|
||||
else if (opt == "Home (~/)") targetDir = browser_.homeDir();
|
||||
else if (opt == "Parent Folder (..)") targetDir = browser_.parentDir();
|
||||
|
||||
if (!targetDir.empty()) {
|
||||
std::string newPath;
|
||||
if (browser_.moveFocusedTo(targetDir, newPath)) {
|
||||
xr_.triggerHaptic(0, 0.050f, 0.0f, 0.7f);
|
||||
xr_.triggerHaptic(1, 0.050f, 0.0f, 0.7f);
|
||||
}
|
||||
}
|
||||
actionMenu_.visible = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
interacted = true;
|
||||
}
|
||||
} else if (actionMenu_.page == 2) {
|
||||
const int dx = navStepX(in.thumbstickX, nowT);
|
||||
const int dy = navStep(in.thumbstickY, nowT);
|
||||
if (dx != 0 || dy != 0) {
|
||||
actionMenu_.selectedOption = (actionMenu_.selectedOption == 0) ? 1 : 0;
|
||||
}
|
||||
const int hov = renderer_.actionMenuBtnHovered();
|
||||
if (hov >= 0) actionMenu_.selectedOption = hov;
|
||||
|
||||
if (in.select || in.togglePlay) {
|
||||
int chosen = (hov >= 0) ? hov : actionMenu_.selectedOption;
|
||||
if (chosen == 0) {
|
||||
std::string delName;
|
||||
if (browser_.deleteFocused(delName)) {
|
||||
xr_.triggerHaptic(0, 0.050f, 0.0f, 0.7f);
|
||||
xr_.triggerHaptic(1, 0.050f, 0.0f, 0.7f);
|
||||
}
|
||||
actionMenu_.visible = false;
|
||||
} else {
|
||||
actionMenu_.visible = false;
|
||||
}
|
||||
interacted = true;
|
||||
}
|
||||
} else if (actionMenu_.page == 3) {
|
||||
const int btnHov = renderer_.actionMenuBtnHovered();
|
||||
const int keyHov = renderer_.actionMenuKeyHovered();
|
||||
|
||||
if (in.select || in.togglePlay) {
|
||||
if (btnHov == 0) {
|
||||
actionMenu_.renameBuffer.clear();
|
||||
xr_.triggerHaptic(0, 0.020f, 0.0f, 0.5f);
|
||||
xr_.triggerHaptic(1, 0.020f, 0.0f, 0.5f);
|
||||
} else if (btnHov == 1) {
|
||||
if (!actionMenu_.renameBuffer.empty()) {
|
||||
actionMenu_.renameBuffer.pop_back();
|
||||
xr_.triggerHaptic(0, 0.020f, 0.0f, 0.5f);
|
||||
xr_.triggerHaptic(1, 0.020f, 0.0f, 0.5f);
|
||||
}
|
||||
} else if (btnHov == 2) {
|
||||
if (!actionMenu_.renameBuffer.empty()) {
|
||||
if (browser_.renameFocused(actionMenu_.renameBuffer)) {
|
||||
xr_.triggerHaptic(0, 0.050f, 0.0f, 0.7f);
|
||||
xr_.triggerHaptic(1, 0.050f, 0.0f, 0.7f);
|
||||
}
|
||||
actionMenu_.visible = false;
|
||||
}
|
||||
} else if (btnHov == 3) {
|
||||
actionMenu_.visible = false;
|
||||
} else if (keyHov >= 0) {
|
||||
static const char* kKeys = "1234567890-_QWERTYUIOPASDFGHJKLZXCVBNM. ";
|
||||
if (keyHov < static_cast<int>(std::strlen(kKeys))) {
|
||||
actionMenu_.renameBuffer.push_back(kKeys[keyHov]);
|
||||
xr_.triggerHaptic(0, 0.015f, 0.0f, 0.4f);
|
||||
xr_.triggerHaptic(1, 0.015f, 0.0f, 0.4f);
|
||||
}
|
||||
}
|
||||
interacted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (actionBtnPressed && browser_.isOpen() && !downloadsWindow_ &&
|
||||
!settings_.isOpen() && pendingDl_.empty() && !browser_.onNetwork()) {
|
||||
openActionMenu();
|
||||
interacted = true;
|
||||
} else if (browser_.isOpen() && !browser_.isScanning() && !downloadsWindow_ &&
|
||||
!settings_.isOpen() && pendingDl_.empty()) {
|
||||
// 2D grid thumbstick navigation: Y steps rows (3 cards), X steps columns (1 card)
|
||||
const int dy = navStep(in.thumbstickY, nowT);
|
||||
const int dx = navStepX(in.thumbstickX, nowT);
|
||||
@@ -1108,14 +1563,13 @@ int App::run() {
|
||||
if ((browser_.isOpen() || downloadsWindow_) && in.recenter && in.headValid) {
|
||||
renderer_.recenterScreen(in.headPos, in.headForward);
|
||||
}
|
||||
// X / Y: download the highlighted entry. Folders ask for confirmation
|
||||
// (with the measured size) first; on a network share a plain file is
|
||||
// copied, and locally there is nothing to download.
|
||||
if (browser_.isOpen() && !downloadsWindow_ && !settings_.isOpen() &&
|
||||
downloadPressed && pendingDl_.empty()) {
|
||||
|
||||
// X: download the highlighted entry when browsing SMB shares. Folders ask for
|
||||
// confirmation (with the measured size) first; plain files download immediately.
|
||||
if (downloadPressed && browser_.isOpen() && !downloadsWindow_ && !settings_.isOpen() &&
|
||||
pendingDl_.empty() && !actionMenu_.visible && browser_.onNetwork()) {
|
||||
const Browser::Entry* fe = browser_.focused();
|
||||
if (fe != nullptr && fe->isDir && !fe->isBack &&
|
||||
browser_.onNetwork()) {
|
||||
if (fe != nullptr && fe->isDir && !fe->isBack) {
|
||||
pendingDl_ = fe->path;
|
||||
pendingDlName_ = fe->label;
|
||||
pendingDlFresh_ = true;
|
||||
@@ -1142,7 +1596,11 @@ int App::run() {
|
||||
// Trigger activates the selection made by the laser or the stick.
|
||||
if (in.select && (hovered >= 0 || std::fabs(in.thumbstickY) > 0.25f)) {
|
||||
auto act = settings_.activate();
|
||||
if (act == SettingsMenu::Action::RecenterScreen) {
|
||||
if (act == SettingsMenu::Action::LockVaultAction) {
|
||||
xr_.triggerHaptic(0, 0.050f, 0.0f, 0.7f);
|
||||
xr_.triggerHaptic(1, 0.050f, 0.0f, 0.7f);
|
||||
performVaultLock();
|
||||
} else if (act == SettingsMenu::Action::RecenterScreen) {
|
||||
if (in.headValid) {
|
||||
renderer_.recenterScreen(in.headPos, in.headForward);
|
||||
}
|
||||
|
||||
@@ -61,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_{};
|
||||
@@ -96,6 +96,9 @@ private:
|
||||
// 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();
|
||||
|
||||
#ifdef PIXELVR_HAVE_FREETYPE
|
||||
Browser browser_;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -204,22 +204,17 @@ VulkanRenderer::~VulkanRenderer() {
|
||||
vkDestroyPipeline(device_, modelPipeline_, nullptr);
|
||||
if (modelLayout_ != VK_NULL_HANDLE)
|
||||
vkDestroyPipelineLayout(device_, modelLayout_, nullptr);
|
||||
if (modelVertexBuffer_ != VK_NULL_HANDLE)
|
||||
vkDestroyBuffer(device_, modelVertexBuffer_, nullptr);
|
||||
if (modelVertexMemory_ != VK_NULL_HANDLE)
|
||||
vkFreeMemory(device_, modelVertexMemory_, nullptr);
|
||||
if (modelIndexBuffer_ != VK_NULL_HANDLE)
|
||||
vkDestroyBuffer(device_, modelIndexBuffer_, nullptr);
|
||||
if (modelIndexMemory_ != VK_NULL_HANDLE)
|
||||
vkFreeMemory(device_, modelIndexMemory_, nullptr);
|
||||
for (auto& t : modelTextures_) {
|
||||
if (t.view != VK_NULL_HANDLE)
|
||||
vkDestroyImageView(device_, t.view, nullptr);
|
||||
if (t.image != VK_NULL_HANDLE)
|
||||
vkDestroyImage(device_, t.image, nullptr);
|
||||
if (t.memory != VK_NULL_HANDLE)
|
||||
vkFreeMemory(device_, t.memory, nullptr);
|
||||
for (int h = 0; h < 2; ++h) {
|
||||
destroyHandModel(handModels_[h]);
|
||||
}
|
||||
if (capsuleVertexBuffer_ != VK_NULL_HANDLE)
|
||||
vkDestroyBuffer(device_, capsuleVertexBuffer_, nullptr);
|
||||
if (capsuleVertexMemory_ != VK_NULL_HANDLE)
|
||||
vkFreeMemory(device_, capsuleVertexMemory_, nullptr);
|
||||
if (capsuleIndexBuffer_ != VK_NULL_HANDLE)
|
||||
vkDestroyBuffer(device_, capsuleIndexBuffer_, nullptr);
|
||||
if (capsuleIndexMemory_ != VK_NULL_HANDLE)
|
||||
vkFreeMemory(device_, capsuleIndexMemory_, nullptr);
|
||||
if (modelWhite_.view != VK_NULL_HANDLE)
|
||||
vkDestroyImageView(device_, modelWhite_.view, nullptr);
|
||||
if (modelWhite_.image != VK_NULL_HANDLE)
|
||||
@@ -245,6 +240,8 @@ VulkanRenderer::~VulkanRenderer() {
|
||||
vkDestroyDescriptorSetLayout(device_, textSetLayout_, nullptr);
|
||||
if (fontView_ != VK_NULL_HANDLE)
|
||||
vkDestroyImageView(device_, fontView_, nullptr);
|
||||
if (fontSampler_ != VK_NULL_HANDLE)
|
||||
vkDestroySampler(device_, fontSampler_, nullptr);
|
||||
if (fontImage_ != VK_NULL_HANDLE)
|
||||
vkDestroyImage(device_, fontImage_, nullptr);
|
||||
if (fontMemory_ != VK_NULL_HANDLE)
|
||||
@@ -1237,12 +1234,15 @@ void VulkanRenderer::updatePointer() {
|
||||
settingsRow_ = -1;
|
||||
downloadsHovered_ = -1;
|
||||
downloadsBtnHovered_ = -1;
|
||||
actionMenuBtnHovered_ = -1;
|
||||
actionMenuKeyHovered_ = -1;
|
||||
|
||||
const bool hubOpen = browserVisible_ || settingsVisible_ ||
|
||||
(downloadsVisible_ && !downloadsModal_);
|
||||
const bool modalOpen = downloadsVisible_ && downloadsModal_;
|
||||
const bool controlBar = controlBarVisible_ && !hubOpen && !modalOpen;
|
||||
if (!hubOpen && !modalOpen && !controlBar) {
|
||||
const bool actionMenuOpen = actionMenu_.visible;
|
||||
const bool hubOpen = (browserVisible_ || settingsVisible_ ||
|
||||
(downloadsVisible_ && !downloadsModal_)) && !actionMenuOpen;
|
||||
const bool modalOpen = (downloadsVisible_ && downloadsModal_) && !actionMenuOpen;
|
||||
const bool controlBar = controlBarVisible_ && !hubOpen && !modalOpen && !actionMenuOpen;
|
||||
if (!hubOpen && !modalOpen && !controlBar && !actionMenuOpen) {
|
||||
return; // No UI surface to point at.
|
||||
}
|
||||
|
||||
@@ -1291,6 +1291,72 @@ void VulkanRenderer::updatePointer() {
|
||||
const float hyy = oL.y + t * dL.y;
|
||||
const Vec3 world = origin + dir * t;
|
||||
|
||||
// 0. File Action Submenu Modal takes highest priority
|
||||
if (actionMenuOpen) {
|
||||
if (hx >= -kPanelW && hx <= kPanelW && hyy >= -kPanelTop && hyy <= kPanelTop) {
|
||||
pointerActive_ = true;
|
||||
pointerHand_ = h;
|
||||
pointerHit_[0] = world.x;
|
||||
pointerHit_[1] = world.y;
|
||||
pointerHit_[2] = world.z;
|
||||
|
||||
if (actionMenu_.page == 0 || actionMenu_.page == 1) {
|
||||
const int optCount = static_cast<int>(actionMenu_.options.size());
|
||||
const float startY = (optCount > 4) ? 0.12f : 0.08f;
|
||||
const float btnHalfW = 0.38f;
|
||||
const float btnHalfH = 0.030f;
|
||||
for (int i = 0; i < optCount; ++i) {
|
||||
const float cy = startY - static_cast<float>(i) * 0.072f;
|
||||
if (std::fabs(hx) <= btnHalfW && std::fabs(hyy - cy) <= btnHalfH) {
|
||||
actionMenuBtnHovered_ = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if (actionMenu_.page == 2) {
|
||||
// Delete confirmation: Confirm (0) vs Cancel (1)
|
||||
const float cy = -0.12f;
|
||||
const float btnHalfW = 0.13f;
|
||||
const float btnHalfH = 0.042f;
|
||||
if (std::fabs(hyy - cy) <= btnHalfH) {
|
||||
if (std::fabs(hx - (-0.15f)) <= btnHalfW) actionMenuBtnHovered_ = 0;
|
||||
else if (std::fabs(hx - 0.15f) <= btnHalfW) actionMenuBtnHovered_ = 1;
|
||||
}
|
||||
} else if (actionMenu_.page == 3) {
|
||||
// Rename page: Top action buttons at cy = 0.08f
|
||||
if (std::fabs(hyy - 0.08f) <= 0.028f) {
|
||||
if (std::fabs(hx - (-0.27f)) <= 0.07f) actionMenuBtnHovered_ = 0; // Clear
|
||||
else if (std::fabs(hx - (-0.09f)) <= 0.09f) actionMenuBtnHovered_ = 1; // Backspace
|
||||
else if (std::fabs(hx - 0.11f) <= 0.09f) actionMenuBtnHovered_ = 2; // Save
|
||||
else if (std::fabs(hx - 0.28f) <= 0.07f) actionMenuBtnHovered_ = 3; // Cancel
|
||||
}
|
||||
// On-screen keyboard keys: 4 rows
|
||||
static const char* kRow0 = "1234567890-_";
|
||||
static const char* kRow1 = "QWERTYUIOP";
|
||||
static const char* kRow2 = "ASDFGHJKL";
|
||||
static const char* kRow3 = "ZXCVBNM. ";
|
||||
auto checkRow = [&](const char* rowKeys, float rowY, int keyOffset, float keyW, float keySpacing) {
|
||||
const int count = static_cast<int>(std::strlen(rowKeys));
|
||||
const float totalW = count * keyW + (count - 1) * keySpacing;
|
||||
const float startX = -0.5f * totalW + 0.5f * keyW;
|
||||
if (std::fabs(hyy - rowY) <= 0.026f) {
|
||||
for (int k = 0; k < count; ++k) {
|
||||
const float kx = startX + k * (keyW + keySpacing);
|
||||
if (std::fabs(hx - kx) <= keyW * 0.5f) {
|
||||
actionMenuKeyHovered_ = keyOffset + k;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
checkRow(kRow0, 0.005f, 0, 0.055f, 0.010f);
|
||||
checkRow(kRow1, -0.055f, 12, 0.065f, 0.012f);
|
||||
checkRow(kRow2, -0.115f, 22, 0.070f, 0.012f);
|
||||
checkRow(kRow3, -0.175f, 31, 0.070f, 0.012f);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Folder Download Confirmation Modal takes top priority
|
||||
if (modalOpen) {
|
||||
if (hx >= -kPanelW && hx <= kPanelW && hyy >= -kPanelTop && hyy <= kPanelTop) {
|
||||
@@ -1327,15 +1393,22 @@ void VulkanRenderer::updatePointer() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Sub-navigation items (indices 3..6)
|
||||
// Sub-navigation items (indices 3..6, and 7/8 if vaultUnlocked_)
|
||||
if (browserSidebarHovered_ < 0) {
|
||||
const float subTabY[4] = {-0.05f, -0.14f, -0.23f, -0.32f};
|
||||
for (int sub = 0; sub < 4; ++sub) {
|
||||
const int activeMainTab = settingsVisible_ ? 2 : (downloadsVisible_ ? 1 : 0);
|
||||
const float subTabY[5] = {-0.05f, -0.14f, -0.23f, -0.32f, -0.41f};
|
||||
const int numSubTabs = (activeMainTab == 1) ? 3 : (vaultUnlocked_ ? 5 : 4);
|
||||
for (int sub = 0; sub < numSubTabs; ++sub) {
|
||||
if (std::fabs(hyy - subTabY[sub]) <= 0.038f) {
|
||||
browserSidebarHovered_ = 3 + sub;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (browserSidebarHovered_ < 0 && vaultUnlocked_) {
|
||||
if (std::fabs(hyy - (-0.485f)) <= 0.035f) {
|
||||
browserSidebarHovered_ = 8; // Lock Vault button
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -1394,7 +1467,7 @@ void VulkanRenderer::updatePointer() {
|
||||
for (int i = 0; i < total && visIdx < 4; ++i) {
|
||||
if (!downloadJobs_.empty()) {
|
||||
const int st = downloadJobs_[i].state;
|
||||
if (downloadsFilter_ == 1 && st != 0 && st != 3) continue;
|
||||
if (downloadsFilter_ == 1 && st != 0 && st != 3 && st != 4) continue;
|
||||
if (downloadsFilter_ == 2 && st != 1) continue;
|
||||
}
|
||||
const float ry = 0.24f - static_cast<float>(visIdx) * 0.175f;
|
||||
@@ -1644,13 +1717,32 @@ bool VulkanRenderer::initText() {
|
||||
dp.poolSizeCount = 1;
|
||||
dp.pPoolSizes = &ps;
|
||||
PIXELVR_VK_CHECK(vkCreateDescriptorPool(device_, &dp, nullptr, &textPool_));
|
||||
|
||||
// Create a dedicated sampler for the SDF font atlas: pure bilinear filtering
|
||||
// with NO anisotropic sampling. Anisotropic filtering averages Euclidean distances
|
||||
// across non-square footprints at oblique angles, distorting the distance field
|
||||
// and causing shimmering artifacts.
|
||||
VkSamplerCreateInfo fsi{VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO};
|
||||
fsi.magFilter = VK_FILTER_LINEAR;
|
||||
fsi.minFilter = VK_FILTER_LINEAR;
|
||||
fsi.mipmapMode = VK_SAMPLER_MIPMAP_MODE_NEAREST;
|
||||
fsi.addressModeU = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE;
|
||||
fsi.addressModeV = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE;
|
||||
fsi.addressModeW = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE;
|
||||
fsi.minLod = 0.0f;
|
||||
fsi.maxLod = 0.0f;
|
||||
fsi.anisotropyEnable = VK_FALSE;
|
||||
fsi.maxAnisotropy = 1.0f;
|
||||
fsi.borderColor = VK_BORDER_COLOR_FLOAT_OPAQUE_BLACK;
|
||||
PIXELVR_VK_CHECK(vkCreateSampler(device_, &fsi, nullptr, &fontSampler_));
|
||||
|
||||
VkDescriptorSetAllocateInfo da{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO};
|
||||
da.descriptorPool = textPool_;
|
||||
da.descriptorSetCount = 1;
|
||||
da.pSetLayouts = &textSetLayout_;
|
||||
PIXELVR_VK_CHECK(vkAllocateDescriptorSets(device_, &da, &textSet_));
|
||||
VkDescriptorImageInfo dii{};
|
||||
dii.sampler = sampler_;
|
||||
dii.sampler = fontSampler_;
|
||||
dii.imageView = fontView_;
|
||||
dii.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
|
||||
VkWriteDescriptorSet w{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET};
|
||||
@@ -2263,8 +2355,8 @@ void VulkanRenderer::renderView(uint32_t viewIndex, uint32_t imageIndex,
|
||||
}
|
||||
|
||||
// Sub-navigation items in rail (contextual to Files / Downloads / Settings)
|
||||
const float subTabY[4] = {-0.05f, -0.14f, -0.23f, -0.32f};
|
||||
const int subCount = (activeMainTab == 1) ? 3 : 4;
|
||||
const float subTabY[5] = {-0.05f, -0.14f, -0.23f, -0.32f, -0.41f};
|
||||
const int subCount = (activeMainTab == 1) ? 3 : (vaultUnlocked_ ? 5 : 4);
|
||||
for (int sub = 0; sub < subCount; ++sub) {
|
||||
const bool hov = (browserSidebarHovered_ == (3 + sub));
|
||||
bool subAct = false;
|
||||
@@ -2273,6 +2365,8 @@ void VulkanRenderer::renderView(uint32_t viewIndex, uint32_t imageIndex,
|
||||
} else if (activeMainTab == 2) {
|
||||
const int effSec = (settingsSection_ >= 10) ? 2 : settingsSection_;
|
||||
subAct = (effSec == sub);
|
||||
} else if (activeMainTab == 0 && sub == 4) {
|
||||
subAct = (browserTitle_.find("Vault") != std::string::npos);
|
||||
}
|
||||
const float subActBg[4] = {
|
||||
themeAccentCol[0] * 0.25f, themeAccentCol[1] * 0.25f,
|
||||
@@ -2283,11 +2377,19 @@ void VulkanRenderer::renderView(uint32_t viewIndex, uint32_t imageIndex,
|
||||
0.40f, 0.025f, hov ? tabHovBord : tabActiveBord);
|
||||
}
|
||||
if (activeMainTab == 0) {
|
||||
const UiShape qIcons[4] = {UiShape::Home, UiShape::CurvedDisplay, UiShape::List, UiShape::Download};
|
||||
const UiShape qIcons[5] = {UiShape::Home, UiShape::CurvedDisplay, UiShape::List, UiShape::Download, UiShape::RoundedBox};
|
||||
drawUiElement(vpPanel, -0.825f, subTabY[sub], 0.015f, 0.015f, qIcons[sub], themeAccentCol);
|
||||
}
|
||||
}
|
||||
|
||||
if (vaultUnlocked_) {
|
||||
const bool lockHov = (browserSidebarHovered_ == 8);
|
||||
const float lockBg[4] = {0.45f, 0.08f, 0.12f, lockHov ? 0.95f : 0.70f};
|
||||
const float lockBord[4] = {0.85f, 0.22f, 0.28f, lockHov ? 1.0f : 0.75f};
|
||||
drawUiElement(vpPanel, -0.695f, -0.485f, 0.172f, 0.030f,
|
||||
UiShape::RoundedBox, lockBg, 0.35f, 0.02f, lockBord);
|
||||
}
|
||||
|
||||
// Standardized, generously readable font scales across the entire Unified Spatial Hub
|
||||
// Base sizes boosted ~35% and multiplied by user-configurable uiTextScale_!
|
||||
const float scHeader = (0.038f * tScale) / fontAtlas_.lineHeight;
|
||||
@@ -2450,9 +2552,15 @@ void VulkanRenderer::renderView(uint32_t viewIndex, uint32_t imageIndex,
|
||||
}
|
||||
}
|
||||
drawFit(-0.855f, 0.02f, 0.34f, scBadge, dimCol, "QUICK FILTERS");
|
||||
const char* qLabels[4] = {"Local Storage", "Videos", "SMB Shares", "Downloads"};
|
||||
for (int sub = 0; sub < 4; ++sub) {
|
||||
drawFit(-0.790f, subTabY[sub], 0.28f, scSub, slateCol, qLabels[sub]);
|
||||
const char* qLabels[5] = {"Local Storage", "Videos", "SMB Shares", "Downloads", "Vault (Private)"};
|
||||
const int qCount = vaultUnlocked_ ? 5 : 4;
|
||||
for (int sub = 0; sub < qCount; ++sub) {
|
||||
const float* col = (sub == 4) ? themeAccentCol : slateCol;
|
||||
drawFit(-0.790f, subTabY[sub], 0.28f, scSub, col, qLabels[sub]);
|
||||
}
|
||||
if (vaultUnlocked_) {
|
||||
const float lockTextCol[4] = {1.0f, 0.50f, 0.50f, 1.0f};
|
||||
drawFitCentered(-0.695f, -0.485f, 0.28f, scSub, lockTextCol, "Lock Vault");
|
||||
}
|
||||
|
||||
// Content header: Breadcrumb + Page switcher
|
||||
@@ -2497,8 +2605,10 @@ void VulkanRenderer::renderView(uint32_t viewIndex, uint32_t imageIndex,
|
||||
if (!browserStatus_.empty()) {
|
||||
drawFit(-0.45f, -0.490f, 0.62f, scDetail, themeAccentCol, browserStatus_);
|
||||
}
|
||||
drawFit(0.18f, -0.490f, 0.68f, scDetail, dimCol,
|
||||
"Stick: Scroll | Trigger: Play | X: Download");
|
||||
const std::string hintText = browserOnNetwork_
|
||||
? "Stick: Scroll | Trigger: Stream | X: Download"
|
||||
: "Stick: Scroll | Trigger: Play | Y: Actions";
|
||||
drawFit(0.18f, -0.490f, 0.68f, scDetail, dimCol, hintText);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
@@ -2523,7 +2633,7 @@ void VulkanRenderer::renderView(uint32_t viewIndex, uint32_t imageIndex,
|
||||
if (!downloadJobs_.empty()) {
|
||||
state = downloadJobs_[i].state;
|
||||
prog = std::clamp(downloadJobs_[i].progress, 0.0f, 1.0f);
|
||||
if (downloadsFilter_ == 1 && state != 0 && state != 3) continue;
|
||||
if (downloadsFilter_ == 1 && state != 0 && state != 3 && state != 4) continue;
|
||||
if (downloadsFilter_ == 2 && state != 1) continue;
|
||||
}
|
||||
const float ry = 0.24f - static_cast<float>(visIdx) * 0.175f;
|
||||
@@ -2596,6 +2706,10 @@ void VulkanRenderer::renderView(uint32_t viewIndex, uint32_t imageIndex,
|
||||
drawFit(-0.84f, subTabY[sub], 0.30f, scSub,
|
||||
(downloadsFilter_ == sub) ? themeTextCol : slateCol, dlSubLabels[sub]);
|
||||
}
|
||||
if (vaultUnlocked_) {
|
||||
const float lockTextCol[4] = {1.0f, 0.50f, 0.50f, 1.0f};
|
||||
drawFitCentered(-0.695f, -0.485f, 0.28f, scSub, lockTextCol, "Lock Vault");
|
||||
}
|
||||
|
||||
drawFit(-0.45f, 0.455f, 0.64f, scHeader * 0.90f, themeTextCol,
|
||||
"Downloads > Transfers");
|
||||
@@ -2616,7 +2730,7 @@ void VulkanRenderer::renderView(uint32_t viewIndex, uint32_t imageIndex,
|
||||
jDet = downloadJobs_[i].detail;
|
||||
state = downloadJobs_[i].state;
|
||||
prog = std::clamp(downloadJobs_[i].progress, 0.0f, 1.0f);
|
||||
if (downloadsFilter_ == 1 && state != 0 && state != 3) continue;
|
||||
if (downloadsFilter_ == 1 && state != 0 && state != 3 && state != 4) continue;
|
||||
if (downloadsFilter_ == 2 && state != 1) continue;
|
||||
} else if (i < static_cast<int>(downloadsRows_.size())) {
|
||||
jName = downloadsRows_[i];
|
||||
@@ -2697,13 +2811,18 @@ void VulkanRenderer::renderView(uint32_t viewIndex, uint32_t imageIndex,
|
||||
std::to_string(activeDownloadCount_));
|
||||
}
|
||||
}
|
||||
const char* setSubLabels[4] = {"Display & Cinema", "UI & Appearance",
|
||||
"Audio & Tracks", "Controls & Input"};
|
||||
const char* setSubLabels[5] = {"Display & Cinema", "UI & Appearance",
|
||||
"Audio & Tracks", "Controls & Input", "Vault & Privacy"};
|
||||
const int effSec = (settingsSection_ >= 10) ? 2 : settingsSection_;
|
||||
for (int sub = 0; sub < 4; ++sub) {
|
||||
const int sCount = vaultUnlocked_ ? 5 : 4;
|
||||
for (int sub = 0; sub < sCount; ++sub) {
|
||||
drawFit(-0.84f, subTabY[sub], 0.30f, scSub,
|
||||
(effSec == sub) ? themeTextCol : slateCol, setSubLabels[sub]);
|
||||
}
|
||||
if (vaultUnlocked_) {
|
||||
const float lockTextCol[4] = {1.0f, 0.50f, 0.50f, 1.0f};
|
||||
drawFitCentered(-0.695f, -0.485f, 0.28f, scSub, lockTextCol, "Lock Vault");
|
||||
}
|
||||
|
||||
drawFit(-0.45f, 0.455f, 0.82f, scHeader * 0.90f, themeTextCol, settingsTitle_);
|
||||
drawFitCentered(0.64f, 0.455f, 0.41f, scDetail, slateCol,
|
||||
@@ -2801,18 +2920,203 @@ void VulkanRenderer::renderView(uint32_t viewIndex, uint32_t imageIndex,
|
||||
drawFitCentered(0.15f, btnCy, btnHalfW * 1.8f, sc, themeTextCol, "Cancel");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 4. File Action Submenu Modal (overlay)
|
||||
// =========================================================================
|
||||
if (actionMenu_.visible && textReady_ && uiPipeline_ != VK_NULL_HANDLE) {
|
||||
vkCmdBindPipeline(commandBuffer_, VK_PIPELINE_BIND_POINT_GRAPHICS, uiPipeline_);
|
||||
const float backdrop[4] = {0.0f, 0.0f, 0.0f, 0.82f};
|
||||
drawUiElement(vpPanel, 0.0f, 0.0f, kPanelW, kPanelTop,
|
||||
UiShape::RoundedBox, backdrop, 0.045f);
|
||||
|
||||
const float mHalfW = (actionMenu_.page == 3) ? 0.48f : 0.44f;
|
||||
const float mHalfH = (actionMenu_.page == 3) ? 0.32f : 0.28f;
|
||||
const float modalBg[4] = {0.032f, 0.045f, 0.075f, 0.98f};
|
||||
drawUiElement(vpPanel, 0.0f, 0.0f, mHalfW, mHalfH,
|
||||
UiShape::RoundedBox, modalBg, 0.07f, 0.02f, themeAccentCol);
|
||||
|
||||
// Danger red palette for delete
|
||||
const float redBg[4] = {0.58f, 0.12f, 0.18f, 0.95f};
|
||||
const float redBgHov[4] = {0.78f, 0.18f, 0.24f, 1.0f};
|
||||
const float redBorder[4] = {0.95f, 0.35f, 0.42f, 0.95f};
|
||||
|
||||
// Slate button palette
|
||||
const float slateBg[4] = {0.10f, 0.14f, 0.22f, 0.92f};
|
||||
const float slateBgHov[4] = {0.20f, 0.28f, 0.42f, 1.0f};
|
||||
const float slateBorder[4] = {0.30f, 0.42f, 0.62f, 0.85f};
|
||||
|
||||
// Green button palette
|
||||
const float greenBg[4] = {0.12f, 0.58f, 0.34f, 0.95f};
|
||||
const float greenBgHov[4] = {0.18f, 0.74f, 0.44f, 1.0f};
|
||||
const float greenBorder[4] = {0.35f, 0.88f, 0.58f, 0.95f};
|
||||
|
||||
// Vault gold/cyan palette
|
||||
const float goldBg[4] = {0.28f, 0.22f, 0.08f, 0.95f};
|
||||
const float goldBgHov[4] = {0.45f, 0.36f, 0.12f, 1.0f};
|
||||
const float goldBorder[4] = {0.95f, 0.82f, 0.35f, 0.95f};
|
||||
|
||||
// Standard option pills and text colors
|
||||
const float tabNormBg[4] = {0.06f, 0.09f, 0.15f, 0.90f};
|
||||
const float tabHovBg[4] = {
|
||||
themeAccentCol[0] * 0.35f, themeAccentCol[1] * 0.35f,
|
||||
themeAccentCol[2] * 0.45f, 0.95f};
|
||||
const float tabNormBord[4] = {0.18f, 0.28f, 0.44f, 0.65f};
|
||||
const float tabActiveBord[4] = {
|
||||
themeAccentCol[0], themeAccentCol[1], themeAccentCol[2], 1.0f};
|
||||
const float slateCol[4] = {0.60f, 0.70f, 0.84f, 1.0f};
|
||||
|
||||
if (actionMenu_.page == 0 || actionMenu_.page == 1) {
|
||||
const int optCount = static_cast<int>(actionMenu_.options.size());
|
||||
const float startY = (optCount > 4) ? 0.12f : 0.08f;
|
||||
const float btnHalfW = 0.38f;
|
||||
const float btnHalfH = 0.028f;
|
||||
for (int i = 0; i < optCount; ++i) {
|
||||
const float cy = startY - static_cast<float>(i) * 0.072f;
|
||||
const bool hov = (actionMenuBtnHovered_ == i) || (actionMenu_.selectedOption == i);
|
||||
const std::string& optStr = actionMenu_.options[i];
|
||||
const bool isDelete = (optStr.find("Delete") != std::string::npos);
|
||||
const bool isVault = (optStr.find("Vault") != std::string::npos);
|
||||
|
||||
const float* bg = hov ? (isDelete ? redBgHov : (isVault ? goldBgHov : tabHovBg))
|
||||
: (isDelete ? redBg : (isVault ? goldBg : tabNormBg));
|
||||
const float* bord = hov ? (isDelete ? redBorder : (isVault ? goldBorder : tabActiveBord))
|
||||
: (isDelete ? redBorder : (isVault ? goldBorder : tabNormBord));
|
||||
drawUiElement(vpPanel, 0.0f, cy, btnHalfW, btnHalfH,
|
||||
UiShape::RoundedBox, bg, 0.35f, 0.02f, bord);
|
||||
}
|
||||
} else if (actionMenu_.page == 2) {
|
||||
// Delete confirmation
|
||||
const float cy = -0.12f;
|
||||
const float btnHalfW = 0.13f;
|
||||
const float btnHalfH = 0.042f;
|
||||
const bool delHov = (actionMenuBtnHovered_ == 0) || (actionMenu_.selectedOption == 0);
|
||||
const bool canHov = (actionMenuBtnHovered_ == 1) || (actionMenu_.selectedOption == 1);
|
||||
drawUiElement(vpPanel, -0.15f, cy, btnHalfW, btnHalfH,
|
||||
UiShape::RoundedBox, delHov ? redBgHov : redBg, 0.42f, 0.03f, redBorder);
|
||||
drawUiElement(vpPanel, 0.15f, cy, btnHalfW, btnHalfH,
|
||||
UiShape::RoundedBox, canHov ? slateBgHov : slateBg, 0.42f, 0.03f, slateBorder);
|
||||
} else if (actionMenu_.page == 3) {
|
||||
// Rename: text input box
|
||||
const float innerBg[4] = {0.018f, 0.026f, 0.046f, 0.90f};
|
||||
drawUiElement(vpPanel, 0.0f, 0.16f, mHalfW - 0.04f, 0.035f,
|
||||
UiShape::RoundedBox, innerBg, 0.25f, 0.02f, themeAccentCol);
|
||||
|
||||
// 4 action buttons
|
||||
const float btnY = 0.08f;
|
||||
const float btnH = 0.025f;
|
||||
auto drawBtn = [&](float cx, float hw, int idx, const float* bg, const float* bgHov, const float* bord) {
|
||||
const bool hov = (actionMenuBtnHovered_ == idx);
|
||||
drawUiElement(vpPanel, cx, btnY, hw, btnH,
|
||||
UiShape::RoundedBox, hov ? bgHov : bg, 0.35f, 0.02f, bord);
|
||||
};
|
||||
drawBtn(-0.27f, 0.07f, 0, slateBg, slateBgHov, slateBorder);
|
||||
drawBtn(-0.09f, 0.09f, 1, slateBg, slateBgHov, slateBorder);
|
||||
drawBtn(0.11f, 0.09f, 2, greenBg, greenBgHov, greenBorder);
|
||||
drawBtn(0.28f, 0.07f, 3, slateBg, slateBgHov, slateBorder);
|
||||
|
||||
// On-screen keyboard keys
|
||||
static const char* kRow0 = "1234567890-_";
|
||||
static const char* kRow1 = "QWERTYUIOP";
|
||||
static const char* kRow2 = "ASDFGHJKL";
|
||||
static const char* kRow3 = "ZXCVBNM. ";
|
||||
auto drawRowKeys = [&](const char* rowKeys, float rowY, int keyOffset, float keyW, float keySpacing) {
|
||||
const int count = static_cast<int>(std::strlen(rowKeys));
|
||||
const float totalW = count * keyW + (count - 1) * keySpacing;
|
||||
const float startX = -0.5f * totalW + 0.5f * keyW;
|
||||
for (int k = 0; k < count; ++k) {
|
||||
const float kx = startX + k * (keyW + keySpacing);
|
||||
const bool hov = (actionMenuKeyHovered_ == keyOffset + k);
|
||||
drawUiElement(vpPanel, kx, rowY, keyW * 0.5f, 0.022f,
|
||||
UiShape::RoundedBox, hov ? slateBgHov : slateBg, 0.35f, 0.015f,
|
||||
hov ? tabActiveBord : slateBorder);
|
||||
}
|
||||
};
|
||||
drawRowKeys(kRow0, 0.005f, 0, 0.055f, 0.010f);
|
||||
drawRowKeys(kRow1, -0.055f, 12, 0.065f, 0.012f);
|
||||
drawRowKeys(kRow2, -0.115f, 22, 0.070f, 0.012f);
|
||||
drawRowKeys(kRow3, -0.175f, 31, 0.070f, 0.012f);
|
||||
}
|
||||
|
||||
// Text pass for Action Menu
|
||||
vkCmdBindPipeline(commandBuffer_, VK_PIPELINE_BIND_POINT_GRAPHICS, textPipeline_);
|
||||
vkCmdBindDescriptorSets(commandBuffer_, VK_PIPELINE_BIND_POINT_GRAPHICS,
|
||||
textPipelineLayout_, 0, 1, &textSet_, 0, nullptr);
|
||||
const float sc = (0.032f * tScale) / fontAtlas_.lineHeight;
|
||||
|
||||
drawFitCentered(0.0f, mHalfH - 0.045f, mHalfW * 1.8f, sc * 1.15f, themeAccentCol, actionMenu_.title);
|
||||
if (!actionMenu_.subtitle.empty() && actionMenu_.page != 2) {
|
||||
drawFitCentered(0.0f, mHalfH - 0.090f, mHalfW * 1.8f, sc * 0.85f, slateCol, actionMenu_.subtitle);
|
||||
}
|
||||
|
||||
if (actionMenu_.page == 0 || actionMenu_.page == 1) {
|
||||
const int optCount = static_cast<int>(actionMenu_.options.size());
|
||||
const float startY = (optCount > 4) ? 0.12f : 0.08f;
|
||||
for (int i = 0; i < optCount; ++i) {
|
||||
const float cy = startY - static_cast<float>(i) * 0.072f;
|
||||
const bool hov = (actionMenuBtnHovered_ == i) || (actionMenu_.selectedOption == i);
|
||||
drawFitCentered(0.0f, cy, 0.38f * 1.85f, sc, hov ? themeTextCol : slateCol, actionMenu_.options[i]);
|
||||
}
|
||||
} else if (actionMenu_.page == 2) {
|
||||
drawFitCentered(0.0f, 0.035f, (mHalfW - 0.06f) * 2.0f, sc, themeTextCol, actionMenu_.subtitle);
|
||||
drawFitCentered(-0.15f, -0.12f, 0.13f * 1.8f, sc, themeTextCol, "Delete");
|
||||
drawFitCentered(0.15f, -0.12f, 0.13f * 1.8f, sc, themeTextCol, "Cancel");
|
||||
} else if (actionMenu_.page == 3) {
|
||||
std::string disp = actionMenu_.renameBuffer.empty() ? "|" : (actionMenu_.renameBuffer + "|");
|
||||
drawFitCentered(0.0f, 0.16f, (mHalfW - 0.06f) * 1.85f, sc, themeTextCol, disp);
|
||||
|
||||
const float btnY = 0.08f;
|
||||
drawFitCentered(-0.27f, btnY, 0.12f, sc * 0.90f, themeTextCol, "Clear");
|
||||
drawFitCentered(-0.09f, btnY, 0.16f, sc * 0.90f, themeTextCol, "Backspace");
|
||||
drawFitCentered(0.11f, btnY, 0.16f, sc * 0.90f, themeTextCol, "Save");
|
||||
drawFitCentered(0.28f, btnY, 0.12f, sc * 0.90f, themeTextCol, "Cancel");
|
||||
|
||||
static const char* kRow0 = "1234567890-_";
|
||||
static const char* kRow1 = "QWERTYUIOP";
|
||||
static const char* kRow2 = "ASDFGHJKL";
|
||||
static const char* kRow3 = "ZXCVBNM. ";
|
||||
auto drawRowText = [&](const char* rowKeys, float rowY, int keyOffset, float keyW, float keySpacing) {
|
||||
const int count = static_cast<int>(std::strlen(rowKeys));
|
||||
const float totalW = count * keyW + (count - 1) * keySpacing;
|
||||
const float startX = -0.5f * totalW + 0.5f * keyW;
|
||||
for (int k = 0; k < count; ++k) {
|
||||
const float kx = startX + k * (keyW + keySpacing);
|
||||
char label[8];
|
||||
if (rowKeys[k] == ' ') std::snprintf(label, sizeof(label), "Space");
|
||||
else std::snprintf(label, sizeof(label), "%c", rowKeys[k]);
|
||||
drawFitCentered(kx, rowY, keyW * 0.85f, sc * 0.85f, themeTextCol, label);
|
||||
}
|
||||
};
|
||||
drawRowText(kRow0, 0.005f, 0, 0.055f, 0.010f);
|
||||
drawRowText(kRow1, -0.055f, 12, 0.065f, 0.012f);
|
||||
drawRowText(kRow2, -0.115f, 22, 0.070f, 0.012f);
|
||||
drawRowText(kRow3, -0.175f, 31, 0.070f, 0.012f);
|
||||
}
|
||||
}
|
||||
|
||||
// Tracked controller 3D models (lit capsule), drawn before the laser/cursor.
|
||||
if (drawModels_ &&
|
||||
(controllers_[0].active || controllers_[1].active) &&
|
||||
modelPipeline_ != VK_NULL_HANDLE) {
|
||||
vkCmdBindPipeline(commandBuffer_, VK_PIPELINE_BIND_POINT_GRAPHICS,
|
||||
modelPipeline_);
|
||||
VkDeviceSize off = 0;
|
||||
vkCmdBindVertexBuffers(commandBuffer_, 0, 1, &modelVertexBuffer_, &off);
|
||||
vkCmdBindIndexBuffer(commandBuffer_, modelIndexBuffer_, 0, modelIndexType_);
|
||||
const Mat4 vpWorld = multiply(proj, viewMat);
|
||||
for (int h = 0; h < 2; ++h) {
|
||||
if (!controllers_[h].active) continue;
|
||||
|
||||
const HandModel& hm = handModels_[h];
|
||||
const bool haveGltf = hm.ready && hm.vertexBuffer != VK_NULL_HANDLE;
|
||||
|
||||
VkDeviceSize off = 0;
|
||||
if (haveGltf) {
|
||||
vkCmdBindVertexBuffers(commandBuffer_, 0, 1, &hm.vertexBuffer, &off);
|
||||
vkCmdBindIndexBuffer(commandBuffer_, hm.indexBuffer, 0, hm.indexType);
|
||||
} else if (capsuleVertexBuffer_ != VK_NULL_HANDLE) {
|
||||
vkCmdBindVertexBuffers(commandBuffer_, 0, 1, &capsuleVertexBuffer_, &off);
|
||||
vkCmdBindIndexBuffer(commandBuffer_, capsuleIndexBuffer_, 0, VK_INDEX_TYPE_UINT16);
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
// The render model is authored around the grip (controller body), so
|
||||
// place it there; fall back to the aim pose if grip is unavailable.
|
||||
const bool useGrip = controllers_[h].gripActive;
|
||||
@@ -2858,18 +3162,18 @@ void VulkanRenderer::renderView(uint32_t viewIndex, uint32_t imageIndex,
|
||||
};
|
||||
const float identityPos[3] = {0.0f, 0.0f, 0.0f};
|
||||
const float identityQuat[4] = {0.0f, 0.0f, 0.0f, 1.0f};
|
||||
if (modelSubmeshes_.empty()) {
|
||||
if (!haveGltf || hm.submeshes.empty()) {
|
||||
const float capsuleColor[4] = {0.13f, 0.13f, 0.16f, 1.0f};
|
||||
drawPart(modelWhite_.set, capsuleColor, 0, modelIndexCount_,
|
||||
drawPart(modelWhite_.set, capsuleColor, 0, capsuleIndexCount_,
|
||||
identityPos, identityQuat);
|
||||
} else {
|
||||
// Node states may carry names (from asset properties) or be bare.
|
||||
// If named, match by name; otherwise fall back to the submesh
|
||||
// ordinal, which lines up for the runtime's flat node list.
|
||||
const bool haveNames =
|
||||
!modelNodeStates_.empty() && !modelNodeStates_[0].name.empty();
|
||||
for (std::size_t si = 0; si < modelSubmeshes_.size(); ++si) {
|
||||
const ModelSubmesh& sm = modelSubmeshes_[si];
|
||||
!hm.nodeStates.empty() && !hm.nodeStates[0].name.empty();
|
||||
for (std::size_t si = 0; si < hm.submeshes.size(); ++si) {
|
||||
const ModelSubmesh& sm = hm.submeshes[si];
|
||||
// The 'status' display node maps the whole atlas to a tiny
|
||||
// quad (the runtime normally textures it); skip it so it does
|
||||
// not render as a tiny sheet of labels on the body.
|
||||
@@ -2877,16 +3181,16 @@ void VulkanRenderer::renderView(uint32_t viewIndex, uint32_t imageIndex,
|
||||
const float* np = identityPos;
|
||||
const float* nq = identityQuat;
|
||||
if (haveNames) {
|
||||
for (const NodeStateVis& ns : modelNodeStates_) {
|
||||
for (const NodeStateVis& ns : hm.nodeStates) {
|
||||
if (ns.name == sm.nodeName) {
|
||||
np = ns.pos;
|
||||
nq = ns.quat;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if (si < modelNodeStates_.size()) {
|
||||
np = modelNodeStates_[si].pos;
|
||||
nq = modelNodeStates_[si].quat;
|
||||
} else if (si < hm.nodeStates.size()) {
|
||||
np = hm.nodeStates[si].pos;
|
||||
nq = hm.nodeStates[si].quat;
|
||||
}
|
||||
// Guard against an unpopulated/degenerate pose (a zero
|
||||
// quaternion would collapse the part and hide the model).
|
||||
@@ -3805,7 +4109,7 @@ bool VulkanRenderer::createModelPipeline() {
|
||||
idx.push_back(v01);
|
||||
}
|
||||
}
|
||||
modelIndexCount_ = static_cast<uint32_t>(idx.size());
|
||||
capsuleIndexCount_ = static_cast<uint32_t>(idx.size());
|
||||
|
||||
auto makeBuffer = [&](const void* data, VkDeviceSize size,
|
||||
VkBufferUsageFlags usage, VkBuffer& buf,
|
||||
@@ -3863,14 +4167,15 @@ bool VulkanRenderer::createModelPipeline() {
|
||||
return true;
|
||||
};
|
||||
if (!makeBuffer(verts.data(), verts.size() * sizeof(V),
|
||||
VK_BUFFER_USAGE_VERTEX_BUFFER_BIT, modelVertexBuffer_,
|
||||
modelVertexMemory_) ||
|
||||
VK_BUFFER_USAGE_VERTEX_BUFFER_BIT, capsuleVertexBuffer_,
|
||||
capsuleVertexMemory_) ||
|
||||
!makeBuffer(idx.data(), idx.size() * sizeof(uint16_t),
|
||||
VK_BUFFER_USAGE_INDEX_BUFFER_BIT, modelIndexBuffer_,
|
||||
modelIndexMemory_)) {
|
||||
PIXELVR_LOG_WARN("Controller model buffers failed");
|
||||
VK_BUFFER_USAGE_INDEX_BUFFER_BIT, capsuleIndexBuffer_,
|
||||
capsuleIndexMemory_)) {
|
||||
PIXELVR_LOG_WARN("Controller capsule model buffers failed");
|
||||
return false;
|
||||
}
|
||||
capsuleIndexCount_ = static_cast<uint32_t>(idx.size());
|
||||
|
||||
// Descriptor set 0, binding 0: the submesh base-color texture (fragment).
|
||||
VkDescriptorSetLayoutBinding texBind{};
|
||||
@@ -3896,10 +4201,10 @@ bool VulkanRenderer::createModelPipeline() {
|
||||
PIXELVR_VK_CHECK(vkCreateSampler(device_, &smp, nullptr, &modelSampler_));
|
||||
|
||||
// One descriptor set per part texture plus the white fallback, with headroom
|
||||
// for a model reload.
|
||||
VkDescriptorPoolSize dps{VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, 32};
|
||||
// for multiple controller models (left + right).
|
||||
VkDescriptorPoolSize dps{VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, 128};
|
||||
VkDescriptorPoolCreateInfo dpi{VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO};
|
||||
dpi.maxSets = 32;
|
||||
dpi.maxSets = 128;
|
||||
dpi.poolSizeCount = 1;
|
||||
dpi.pPoolSizes = &dps;
|
||||
PIXELVR_VK_CHECK(
|
||||
@@ -4135,21 +4440,68 @@ bool VulkanRenderer::createModelTexture(const uint8_t* rgba, int width,
|
||||
return true;
|
||||
}
|
||||
|
||||
void VulkanRenderer::setControllerNodeStates(
|
||||
const std::vector<NodeStateVis>& states) {
|
||||
modelNodeStates_ = states;
|
||||
void VulkanRenderer::destroyHandModel(HandModel& hm) {
|
||||
if (hm.vertexBuffer != VK_NULL_HANDLE) {
|
||||
vkDestroyBuffer(device_, hm.vertexBuffer, nullptr);
|
||||
hm.vertexBuffer = VK_NULL_HANDLE;
|
||||
}
|
||||
if (hm.vertexMemory != VK_NULL_HANDLE) {
|
||||
vkFreeMemory(device_, hm.vertexMemory, nullptr);
|
||||
hm.vertexMemory = VK_NULL_HANDLE;
|
||||
}
|
||||
if (hm.indexBuffer != VK_NULL_HANDLE) {
|
||||
vkDestroyBuffer(device_, hm.indexBuffer, nullptr);
|
||||
hm.indexBuffer = VK_NULL_HANDLE;
|
||||
}
|
||||
if (hm.indexMemory != VK_NULL_HANDLE) {
|
||||
vkFreeMemory(device_, hm.indexMemory, nullptr);
|
||||
hm.indexMemory = VK_NULL_HANDLE;
|
||||
}
|
||||
for (auto& t : hm.textures) {
|
||||
if (t.view != VK_NULL_HANDLE) vkDestroyImageView(device_, t.view, nullptr);
|
||||
if (t.image != VK_NULL_HANDLE) vkDestroyImage(device_, t.image, nullptr);
|
||||
if (t.memory != VK_NULL_HANDLE) vkFreeMemory(device_, t.memory, nullptr);
|
||||
}
|
||||
hm.textures.clear();
|
||||
hm.submeshes.clear();
|
||||
hm.nodeStates.clear();
|
||||
hm.indexCount = 0;
|
||||
hm.ready = false;
|
||||
}
|
||||
|
||||
bool VulkanRenderer::loadControllerModel(const std::vector<uint8_t>& glb) {
|
||||
const GltfMesh mesh = loadGlb(glb);
|
||||
if (!mesh.ok || mesh.indices.empty()) {
|
||||
PIXELVR_LOG_WARN("Controller model: no usable geometry in glTF");
|
||||
return false;
|
||||
void VulkanRenderer::setControllerNodeStates(int hand,
|
||||
const std::vector<NodeStateVis>& states) {
|
||||
if (hand >= 0 && hand < 2) {
|
||||
handModels_[hand].nodeStates = states;
|
||||
}
|
||||
vkDeviceWaitIdle(device_); // the old (capsule) buffers may be in flight
|
||||
}
|
||||
|
||||
auto upload = [&](const void* data, VkDeviceSize size, VkBufferUsageFlags usage,
|
||||
VkBuffer& buf, VkDeviceMemory& mem) -> bool {
|
||||
namespace {
|
||||
|
||||
GltfMesh mirrorGltfMesh(const GltfMesh& inMesh) {
|
||||
GltfMesh outMesh = inMesh;
|
||||
// Interleaved layout: px, py, pz, nx, ny, nz, u, v
|
||||
for (std::size_t i = 0; i + 7 < outMesh.vertices.size(); i += 8) {
|
||||
outMesh.vertices[i + 0] = -outMesh.vertices[i + 0]; // negate X position
|
||||
outMesh.vertices[i + 3] = -outMesh.vertices[i + 3]; // negate X normal
|
||||
}
|
||||
// Flip winding order of each triangle
|
||||
for (std::size_t i = 0; i + 2 < outMesh.indices.size(); i += 3) {
|
||||
std::swap(outMesh.indices[i + 1], outMesh.indices[i + 2]);
|
||||
}
|
||||
return outMesh;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool VulkanRenderer::uploadHandMesh(int hand, const GltfMesh& mesh) {
|
||||
if (hand < 0 || hand >= 2) return false;
|
||||
if (!mesh.ok || mesh.indices.empty()) return false;
|
||||
|
||||
vkDeviceWaitIdle(device_);
|
||||
|
||||
auto upload = [this](const void* data, VkDeviceSize size, VkBufferUsageFlags usage,
|
||||
VkBuffer& buf, VkDeviceMemory& mem) -> bool {
|
||||
VkBuffer staging = VK_NULL_HANDLE;
|
||||
VkDeviceMemory stagingMem = VK_NULL_HANDLE;
|
||||
VkBufferCreateInfo bi{VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO};
|
||||
@@ -4200,39 +4552,19 @@ bool VulkanRenderer::loadControllerModel(const std::vector<uint8_t>& glb) {
|
||||
vkFreeMemory(device_, stagingMem, nullptr);
|
||||
return true;
|
||||
};
|
||||
auto destroy = [&](VkBuffer& b, VkDeviceMemory& m) {
|
||||
if (b != VK_NULL_HANDLE) {
|
||||
vkDestroyBuffer(device_, b, nullptr);
|
||||
b = VK_NULL_HANDLE;
|
||||
}
|
||||
if (m != VK_NULL_HANDLE) {
|
||||
vkFreeMemory(device_, m, nullptr);
|
||||
m = VK_NULL_HANDLE;
|
||||
}
|
||||
};
|
||||
destroy(modelVertexBuffer_, modelVertexMemory_);
|
||||
destroy(modelIndexBuffer_, modelIndexMemory_);
|
||||
modelIndexCount_ = 0;
|
||||
|
||||
HandModel& hm = handModels_[hand];
|
||||
destroyHandModel(hm);
|
||||
|
||||
if (!upload(mesh.vertices.data(), mesh.vertices.size() * sizeof(float),
|
||||
VK_BUFFER_USAGE_VERTEX_BUFFER_BIT, modelVertexBuffer_,
|
||||
modelVertexMemory_) ||
|
||||
VK_BUFFER_USAGE_VERTEX_BUFFER_BIT, hm.vertexBuffer, hm.vertexMemory) ||
|
||||
!upload(mesh.indices.data(), mesh.indices.size() * sizeof(uint32_t),
|
||||
VK_BUFFER_USAGE_INDEX_BUFFER_BIT, modelIndexBuffer_,
|
||||
modelIndexMemory_)) {
|
||||
VK_BUFFER_USAGE_INDEX_BUFFER_BIT, hm.indexBuffer, hm.indexMemory)) {
|
||||
destroyHandModel(hm);
|
||||
return false;
|
||||
}
|
||||
modelIndexCount_ = static_cast<uint32_t>(mesh.indices.size());
|
||||
modelIndexType_ = VK_INDEX_TYPE_UINT32;
|
||||
|
||||
// Free any textures from a previous model, then upload this model's base-
|
||||
// color textures (one per part) and build the submesh draw list.
|
||||
for (auto& t : modelTextures_) {
|
||||
if (t.view != VK_NULL_HANDLE) vkDestroyImageView(device_, t.view, nullptr);
|
||||
if (t.image != VK_NULL_HANDLE) vkDestroyImage(device_, t.image, nullptr);
|
||||
if (t.memory != VK_NULL_HANDLE) vkFreeMemory(device_, t.memory, nullptr);
|
||||
}
|
||||
modelTextures_.clear();
|
||||
modelSubmeshes_.clear();
|
||||
hm.indexCount = static_cast<uint32_t>(mesh.indices.size());
|
||||
hm.indexType = VK_INDEX_TYPE_UINT32;
|
||||
|
||||
std::vector<VkDescriptorSet> imageSets(mesh.images.size(), modelWhite_.set);
|
||||
for (std::size_t i = 0; i < mesh.images.size(); ++i) {
|
||||
@@ -4241,10 +4573,11 @@ bool VulkanRenderer::loadControllerModel(const std::vector<uint8_t>& glb) {
|
||||
ModelTexture t;
|
||||
if (createModelTexture(img.rgba.data(), img.width, img.height, t)) {
|
||||
imageSets[i] = t.set;
|
||||
modelTextures_.push_back(t);
|
||||
hm.textures.push_back(t);
|
||||
}
|
||||
}
|
||||
modelSubmeshes_.reserve(mesh.submeshes.size());
|
||||
|
||||
hm.submeshes.reserve(mesh.submeshes.size());
|
||||
for (const GltfSubmesh& s : mesh.submeshes) {
|
||||
ModelSubmesh ms;
|
||||
ms.firstIndex = s.firstIndex;
|
||||
@@ -4255,14 +4588,38 @@ bool VulkanRenderer::loadControllerModel(const std::vector<uint8_t>& glb) {
|
||||
? imageSets[s.image]
|
||||
: modelWhite_.set;
|
||||
for (int k = 0; k < 4; ++k) ms.color[k] = s.baseColor[k];
|
||||
modelSubmeshes_.push_back(ms);
|
||||
hm.submeshes.push_back(ms);
|
||||
}
|
||||
hm.ready = true;
|
||||
|
||||
PIXELVR_LOG_INFO(
|
||||
"Controller model loaded: %u indices, %zu submeshes, %zu textures",
|
||||
modelIndexCount_, modelSubmeshes_.size(), modelTextures_.size());
|
||||
"Controller model loaded for %s hand: %u indices, %zu submeshes, %zu textures",
|
||||
hand == 0 ? "left" : "right",
|
||||
hm.indexCount, hm.submeshes.size(), hm.textures.size());
|
||||
return true;
|
||||
}
|
||||
|
||||
bool VulkanRenderer::loadControllerModel(int hand, const std::vector<uint8_t>& glb) {
|
||||
if (hand < 0 || hand >= 2) return false;
|
||||
const GltfMesh mesh = loadGlb(glb);
|
||||
if (!mesh.ok || mesh.indices.empty()) {
|
||||
PIXELVR_LOG_WARN("Controller model for hand %d: no usable geometry in glTF", hand);
|
||||
return false;
|
||||
}
|
||||
bool ok = uploadHandMesh(hand, mesh);
|
||||
if (ok) {
|
||||
const int other = 1 - hand;
|
||||
if (!handModels_[other].ready) {
|
||||
PIXELVR_LOG_INFO("Mirroring %s model to %s hand as initial geometry",
|
||||
hand == 0 ? "left" : "right",
|
||||
other == 0 ? "left" : "right");
|
||||
GltfMesh mirrored = mirrorGltfMesh(mesh);
|
||||
uploadHandMesh(other, mirrored);
|
||||
}
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool VulkanRenderer::importDmabufImage(const DrmVideoFrame& frame, VkImage& image,
|
||||
VkDeviceMemory& memory, VkImageView& view,
|
||||
bool& fdConsumed) {
|
||||
|
||||
@@ -13,6 +13,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
|
||||
@@ -152,6 +153,8 @@ public:
|
||||
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;
|
||||
@@ -179,6 +182,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.
|
||||
@@ -195,8 +214,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).
|
||||
@@ -206,7 +228,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,
|
||||
@@ -264,11 +289,14 @@ public:
|
||||
hudAudioEnabled_ = audioEnabled;
|
||||
}
|
||||
|
||||
// Per-hand laser pointer toggle (grip press). A hand with the laser off
|
||||
// neither casts a beam nor takes part in the UI raycast, so the other hand
|
||||
// can own the pointer while this one drives a panel with its stick/d-pad.
|
||||
// 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 (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;
|
||||
@@ -383,6 +411,12 @@ private:
|
||||
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};
|
||||
@@ -428,6 +462,7 @@ private:
|
||||
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;
|
||||
@@ -468,10 +503,12 @@ private:
|
||||
bool pointerActive_ = false;
|
||||
int pointerRow_ = -1;
|
||||
int pointerHand_ = -1;
|
||||
// Whether each hand's laser pointer is on (toggled with the grip button).
|
||||
bool laserOn_[2] = {true, true};
|
||||
// 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;
|
||||
@@ -575,18 +612,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;
|
||||
@@ -601,13 +638,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);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "ui/browser.hpp"
|
||||
#include "app/vault_manager.hpp"
|
||||
|
||||
#include "util/logging.hpp"
|
||||
#include "util/smb_lock.hpp"
|
||||
@@ -144,7 +145,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_) {
|
||||
@@ -400,6 +405,142 @@ void Browser::openDrives() {
|
||||
}
|
||||
}
|
||||
|
||||
void Browser::openVault() {
|
||||
if (!VaultManager::instance().isUnlocked()) return;
|
||||
VaultManager::instance().ensureDirectories();
|
||||
openPath(VaultManager::instance().vaultDir());
|
||||
}
|
||||
|
||||
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_.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) {
|
||||
@@ -432,8 +573,17 @@ 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;
|
||||
}
|
||||
@@ -457,7 +607,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;
|
||||
}
|
||||
@@ -488,19 +638,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -528,11 +695,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;
|
||||
@@ -652,11 +841,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;
|
||||
@@ -829,8 +1039,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.
|
||||
@@ -847,6 +1057,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;
|
||||
@@ -984,6 +1226,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>
|
||||
@@ -40,6 +41,17 @@ public:
|
||||
void openVideos();
|
||||
void openDownloads();
|
||||
void openDrives();
|
||||
void openVault();
|
||||
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_; }
|
||||
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).
|
||||
@@ -104,13 +116,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
|
||||
@@ -146,6 +166,7 @@ private:
|
||||
void scanSmbInto(const std::string& uri, 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;
|
||||
@@ -176,6 +197,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,5 @@
|
||||
#include "ui/settings_menu.hpp"
|
||||
#include "app/vault_manager.hpp"
|
||||
|
||||
#include "util/logging.hpp"
|
||||
|
||||
@@ -185,6 +186,40 @@ void SettingsMenu::refresh() {
|
||||
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_ == 10) {
|
||||
title_ = "Settings > Select Video Track";
|
||||
for (std::size_t i = 0; i < videoStreams_.size(); ++i) {
|
||||
@@ -330,6 +365,40 @@ 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::RecenterScreen:
|
||||
break;
|
||||
case Action::Close:
|
||||
@@ -378,6 +447,7 @@ 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);
|
||||
listeningFunc_ = -1;
|
||||
status_ = "Bound to " + std::string(kButtonNames[b]);
|
||||
saveConfig();
|
||||
|
||||
@@ -37,6 +37,11 @@ public:
|
||||
PickAudioStream,
|
||||
PickLanguage,
|
||||
RecenterScreen,
|
||||
CycleVaultAutoLockHeadset,
|
||||
CycleVaultTimeout,
|
||||
ListenVaultMoveBtn,
|
||||
RecordVaultCombo,
|
||||
LockVaultAction,
|
||||
Close
|
||||
};
|
||||
|
||||
@@ -54,6 +59,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_; }
|
||||
@@ -110,8 +116,6 @@ public:
|
||||
void saveConfig() const;
|
||||
|
||||
private:
|
||||
void refresh();
|
||||
|
||||
bool open_ = false;
|
||||
std::string title_ = "Settings & Controller Remap";
|
||||
std::string status_ = "Point + Trigger: Change Setting | Grip: Recenter | B/Y: Close";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "ui/thumbnail_cache.hpp"
|
||||
#include "app/vault_manager.hpp"
|
||||
#include "util/logging.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
@@ -96,6 +97,32 @@ bool ThumbnailCache::getMeta(const std::string& videoPath, MediaMeta& outMeta) {
|
||||
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) {
|
||||
@@ -255,9 +282,11 @@ void ThumbnailCache::workerLoop() {
|
||||
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 = cacheDir_ + "/" + baseHash + ".rgba";
|
||||
const std::string metaFile = cacheDir_ + "/" + baseHash + ".meta";
|
||||
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) {
|
||||
|
||||
@@ -34,6 +34,9 @@ public:
|
||||
// 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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include "xr/xr_result.hpp"
|
||||
#include "util/logging.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cstring>
|
||||
|
||||
@@ -236,8 +237,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 +256,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 +278,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 +350,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 +393,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 +446,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 +459,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 +468,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 +490,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 +501,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 +544,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");
|
||||
@@ -645,6 +733,69 @@ void XrContext::syncActions() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
|
||||
@@ -61,6 +61,12 @@ struct XrInputState {
|
||||
// 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,
|
||||
@@ -94,12 +100,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,
|
||||
@@ -161,6 +169,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,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;
|
||||
@@ -127,7 +127,7 @@ def main() -> int:
|
||||
'mkdir -p "$target/shaders"; printf %s "$target"'
|
||||
)
|
||||
|
||||
# 2. Copy binary, shaders and the direct-run helper.
|
||||
# 2. Copy binary, shaders, launcher, and icon.
|
||||
print("Copying binary ...")
|
||||
scp([str(binary)], f"{REMOTE_DIR}/pixelvr")
|
||||
if shaders:
|
||||
@@ -136,6 +136,10 @@ def main() -> int:
|
||||
launch_helper = PROJECT_ROOT / "tools" / "launch.sh"
|
||||
if launch_helper.is_file():
|
||||
scp([str(launch_helper)], f"{REMOTE_DIR}/launch.sh")
|
||||
for asset_name in ("icon.png", "hero.png", "capsule.png", "header.png", "logo.png"):
|
||||
asset_file = PROJECT_ROOT / "assets" / asset_name
|
||||
if asset_file.is_file():
|
||||
scp([str(asset_file)], f"{REMOTE_DIR}/{asset_name}")
|
||||
|
||||
# 3. Register the Non-Steam shortcut (runs on the host, not in Sniper).
|
||||
request = {
|
||||
@@ -165,6 +169,83 @@ def main() -> int:
|
||||
if isinstance(response, dict) and response.get("error"):
|
||||
raise RuntimeError(f"shortcut registration failed: {response['error']}")
|
||||
|
||||
# 3b. Sanitize shortcuts.vdf to remove the 'Devkit Game: ' prefix and set icon.
|
||||
sanitize_script = (
|
||||
"import glob, os, struct, shutil\n"
|
||||
"OBJ, STR, INT, U64, END = 0, 1, 2, 7, 8\n"
|
||||
"def read_cstr(buf, pos):\n"
|
||||
" end = buf.find(b'\\0', pos)\n"
|
||||
" return buf[pos:end].decode('utf-8', 'surrogateescape'), end + 1\n"
|
||||
"def parse_obj(buf, pos):\n"
|
||||
" entries = {}\n"
|
||||
" while pos < len(buf):\n"
|
||||
" kind = buf[pos]\n"
|
||||
" if kind in (END, 0x0B): return entries, pos + 1\n"
|
||||
" key, pos = read_cstr(buf, pos + 1)\n"
|
||||
" if kind == OBJ: value, pos = parse_obj(buf, pos)\n"
|
||||
" elif kind == STR: value, pos = read_cstr(buf, pos)\n"
|
||||
" elif kind == INT: value = struct.unpack_from('<i', buf, pos)[0]; pos += 4\n"
|
||||
" elif kind == U64: value = struct.unpack_from('<Q', buf, pos)[0]; pos += 8\n"
|
||||
" else: break\n"
|
||||
" entries[key] = [kind, value]\n"
|
||||
" return entries, pos\n"
|
||||
"def dump(entries):\n"
|
||||
" out = bytearray()\n"
|
||||
" for key, (kind, value) in entries.items():\n"
|
||||
" kb = key.encode('utf-8', 'surrogateescape')\n"
|
||||
" if kind == OBJ: out += bytes([OBJ]) + kb + b'\\0' + dump(value) + bytes([END])\n"
|
||||
" elif kind == STR: out += bytes([STR]) + kb + b'\\0' + value.encode('utf-8', 'surrogateescape') + b'\\0'\n"
|
||||
" elif kind == INT: out += bytes([INT]) + kb + b'\\0' + struct.pack('<i', value)\n"
|
||||
" elif kind == U64: out += bytes([U64]) + kb + b'\\0' + struct.pack('<Q', value)\n"
|
||||
" return bytes(out)\n"
|
||||
"for vdf in glob.glob(os.path.expanduser('~/.steam/steam/userdata/*/config/shortcuts.vdf')):\n"
|
||||
" try:\n"
|
||||
" with open(vdf, 'rb') as f: data = f.read()\n"
|
||||
" if not data or data[0] != OBJ: continue\n"
|
||||
" key, pos = read_cstr(data, 1)\n"
|
||||
" s_dict, _ = parse_obj(data, pos)\n"
|
||||
" mod = False\n"
|
||||
" for k, v in s_dict.items():\n"
|
||||
" if isinstance(v, list) and v[0] == OBJ:\n"
|
||||
" e = v[1]\n"
|
||||
" name = e.get('AppName', [STR, ''])[1]\n"
|
||||
" if 'Pixel' in name:\n"
|
||||
" e['AppName'] = [STR, 'Pixel VR Player']\n"
|
||||
" e['Devkit'] = [INT, 0]\n"
|
||||
" e['DevkitGameID'] = [STR, '']\n"
|
||||
" icon_p = os.path.expanduser('~/" + REMOTE_DIR + "/icon.png')\n"
|
||||
" hero_p = os.path.expanduser('~/" + REMOTE_DIR + "/hero.png')\n"
|
||||
" capsule_p = os.path.expanduser('~/" + REMOTE_DIR + "/capsule.png')\n"
|
||||
" header_p = os.path.expanduser('~/" + REMOTE_DIR + "/header.png')\n"
|
||||
" logo_p = os.path.expanduser('~/" + REMOTE_DIR + "/logo.png')\n"
|
||||
" grid = os.path.join(os.path.dirname(os.path.dirname(vdf)), 'config', 'grid')\n"
|
||||
" os.makedirs(grid, exist_ok=True)\n"
|
||||
" raw_id = e.get('appid', [INT, 0])[1] & 0xFFFFFFFF\n"
|
||||
" if os.path.isfile(icon_p):\n"
|
||||
" e['icon'] = [STR, icon_p]\n"
|
||||
" shutil.copyfile(icon_p, os.path.join(grid, f'{raw_id}_icon.png'))\n"
|
||||
" if os.path.isfile(capsule_p):\n"
|
||||
" shutil.copyfile(capsule_p, os.path.join(grid, f'{raw_id}p.png'))\n"
|
||||
" elif os.path.isfile(icon_p):\n"
|
||||
" shutil.copyfile(icon_p, os.path.join(grid, f'{raw_id}p.png'))\n"
|
||||
" if os.path.isfile(header_p):\n"
|
||||
" shutil.copyfile(header_p, os.path.join(grid, f'{raw_id}.png'))\n"
|
||||
" elif os.path.isfile(icon_p):\n"
|
||||
" shutil.copyfile(icon_p, os.path.join(grid, f'{raw_id}.png'))\n"
|
||||
" if os.path.isfile(hero_p):\n"
|
||||
" shutil.copyfile(hero_p, os.path.join(grid, f'{raw_id}_hero.png'))\n"
|
||||
" if os.path.isfile(logo_p):\n"
|
||||
" shutil.copyfile(logo_p, os.path.join(grid, f'{raw_id}_logo.png'))\n"
|
||||
" mod = True\n"
|
||||
" if mod:\n"
|
||||
" tmp = vdf + '.tmp'\n"
|
||||
" with open(tmp, 'wb') as f: f.write(dump({key: [OBJ, s_dict]}) + bytes([END]))\n"
|
||||
" os.replace(tmp, vdf)\n"
|
||||
" except Exception:\n"
|
||||
" pass\n"
|
||||
)
|
||||
ssh(f"python3 -c {shlex.quote(sanitize_script)}")
|
||||
|
||||
# 4. Optionally launch it in the headset.
|
||||
if args.launch:
|
||||
print("Launching in the headset ...")
|
||||
@@ -184,7 +265,7 @@ def main() -> int:
|
||||
print(f"Deployed to {host}:~/{REMOTE_DIR}")
|
||||
if player_args:
|
||||
print(" argv:", " ".join(["./pixelvr", *player_args]))
|
||||
print(f" Launch from Steam > Library > Non-Steam > 'Devkit Game: {GAME}'")
|
||||
print(" Launch from Steam > Library > Non-Steam > 'Pixel VR Player'")
|
||||
print(f" Or over SSH: ssh {host} '{REMOTE_DIR}/launch.sh <video>'")
|
||||
return 0
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
echo ===============================================================
|
||||
echo Pixel VR Player - Steam Frame Remote Installer
|
||||
echo ===============================================================
|
||||
echo This tool installs Pixel VR Player on your Valve Steam Frame
|
||||
echo over your local Wi-Fi network directly from your PC.
|
||||
echo ===============================================================
|
||||
echo.
|
||||
|
||||
set "FRAME_IP=steamframe.local"
|
||||
set /p "INPUT_IP=Enter Steam Frame IP address [press Enter for steamframe.local]: "
|
||||
if not "!INPUT_IP!"=="" set "FRAME_IP=!INPUT_IP!"
|
||||
|
||||
echo.
|
||||
echo Connecting to steamos@!FRAME_IP!...
|
||||
echo (If prompted for a password, enter your SteamOS desktop password)
|
||||
echo.
|
||||
|
||||
where ssh >nul 2>&1
|
||||
if %ERRORLEVEL% neq 0 (
|
||||
echo [ERROR] SSH is not installed or not in PATH on this PC.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
ssh -o StrictHostKeyChecking=no steamos@!FRAME_IP! "curl -fsSL https://raw.githubusercontent.com/Pixelplanet/Pixel-VR-Player/main/tools/install.sh | sh"
|
||||
|
||||
if %ERRORLEVEL% equ 0 (
|
||||
echo.
|
||||
echo ===============================================================
|
||||
echo [SUCCESS] Pixel VR Player installed on your Steam Frame!
|
||||
echo It is now in your Steam Library under Non-Steam Games.
|
||||
echo ===============================================================
|
||||
) else (
|
||||
echo.
|
||||
echo ===============================================================
|
||||
echo [NOTE] If connection failed, please ensure:
|
||||
echo 1. Both your PC and Steam Frame are on the same Wi-Fi.
|
||||
echo 2. SSH is enabled on the headset.
|
||||
echo (In Desktop Mode, open Konsole and run 'passwd',
|
||||
echo then 'sudo systemctl start sshd', or enable Dev Mode).
|
||||
echo ===============================================================
|
||||
)
|
||||
|
||||
echo.
|
||||
pause
|
||||
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env bash
|
||||
# Pixel VR Player - Remote installer from Mac / Linux to Steam Frame
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
echo "==============================================================="
|
||||
echo " Pixel VR Player - Steam Frame Remote Installer"
|
||||
echo "==============================================================="
|
||||
echo " This tool installs Pixel VR Player on your Valve Steam Frame"
|
||||
echo " over your local Wi-Fi network directly from your Mac or PC."
|
||||
echo "==============================================================="
|
||||
echo ""
|
||||
|
||||
read -rp "Enter Steam Frame IP address [press Enter for steamframe.local]: " INPUT_IP
|
||||
FRAME_IP="${INPUT_IP:-steamframe.local}"
|
||||
|
||||
echo ""
|
||||
echo "Connecting to steamos@${FRAME_IP}..."
|
||||
echo "(If prompted for a password, enter your SteamOS desktop password)"
|
||||
echo ""
|
||||
|
||||
if ssh -o StrictHostKeyChecking=no "steamos@${FRAME_IP}" "curl -fsSL https://raw.githubusercontent.com/Pixelplanet/Pixel-VR-Player/main/tools/install.sh | sh"; then
|
||||
echo ""
|
||||
echo "==============================================================="
|
||||
echo " [SUCCESS] Pixel VR Player installed on your Steam Frame!"
|
||||
echo " It is now in your Steam Library under Non-Steam Games."
|
||||
echo "==============================================================="
|
||||
else
|
||||
echo ""
|
||||
echo "==============================================================="
|
||||
echo " [NOTE] If connection failed, please ensure:"
|
||||
echo " 1. Both devices are on the same Wi-Fi network."
|
||||
echo " 2. SSH is enabled on the headset."
|
||||
echo " (In Desktop Mode, open Konsole and run 'passwd',"
|
||||
echo " then 'sudo systemctl start sshd', or enable Dev Mode)."
|
||||
echo "==============================================================="
|
||||
fi
|
||||
@@ -230,7 +230,8 @@ def find(shortcuts, name, exe):
|
||||
continue
|
||||
stored_exe = entry.get("Exe", [STR, ""])[1].strip('"')
|
||||
stored_name = entry.get("AppName", [STR, ""])[1]
|
||||
if (exe and stored_exe == exe) or stored_name == name:
|
||||
devkit_id = entry.get("DevkitGameID", [STR, ""])[1]
|
||||
if (exe and stored_exe == exe) or stored_name == name or stored_name in ("Devkit Game: PixelVRPlayer", "Devkit Game: Pixel VR Player") or devkit_id == "PixelVRPlayer":
|
||||
return key, entry
|
||||
return None, None
|
||||
|
||||
@@ -245,7 +246,11 @@ def main():
|
||||
with open(path, "rb") as handle:
|
||||
buf = handle.read()
|
||||
root = parse_file(buf)
|
||||
shortcuts = root["shortcuts"][1]
|
||||
root_key = next((k for k in root if k.lower() == "shortcuts"), None)
|
||||
if root_key is None:
|
||||
root_key = "shortcuts"
|
||||
root[root_key] = [OBJ, {}]
|
||||
shortcuts = root[root_key][1]
|
||||
|
||||
key, entry = find(shortcuts, name, exe)
|
||||
|
||||
@@ -258,7 +263,7 @@ def main():
|
||||
ordered = sorted(shortcuts, key=lambda k: int(k) if k.isdigit() else 1 << 30)
|
||||
for index, old in enumerate(ordered):
|
||||
renumbered[str(index)] = shortcuts[old]
|
||||
root["shortcuts"][1] = renumbered
|
||||
root[root_key][1] = renumbered
|
||||
print("removed '%s'" % name)
|
||||
else:
|
||||
quoted_exe = '"%s"' % exe
|
||||
@@ -272,11 +277,12 @@ def main():
|
||||
shortcuts[str(index)] = [OBJ, entry]
|
||||
if "appid" not in entry:
|
||||
entry["appid"] = [INT, to_i32(app_id(quoted_exe, name))]
|
||||
icon_path = os.path.join(start_dir, "icon.png") if os.path.isfile(os.path.join(start_dir, "icon.png")) else ""
|
||||
for field, value in (
|
||||
("AppName", [STR, name]),
|
||||
("Exe", [STR, quoted_exe]),
|
||||
("StartDir", [STR, '"%s"' % start_dir]),
|
||||
("icon", [STR, ""]),
|
||||
("icon", [STR, icon_path]),
|
||||
("ShortcutPath", [STR, ""]),
|
||||
("LaunchOptions", [STR, ""]),
|
||||
("IsHidden", [INT, 0]),
|
||||
@@ -291,6 +297,31 @@ def main():
|
||||
):
|
||||
entry[field] = value
|
||||
entry.setdefault("tags", [OBJ, {}])
|
||||
if icon_path and os.path.isfile(icon_path):
|
||||
try:
|
||||
import shutil
|
||||
steam_user_dir = os.path.dirname(os.path.dirname(path))
|
||||
grid_dir = os.path.join(steam_user_dir, "config", "grid")
|
||||
if icon_path and os.path.isfile(icon_path):
|
||||
shutil.copyfile(icon_path, os.path.join(grid_dir, "%d_icon.png" % raw_appid))
|
||||
capsule_path = os.path.join(os.path.dirname(icon_path), "capsule.png") if icon_path else ""
|
||||
if capsule_path and os.path.isfile(capsule_path):
|
||||
shutil.copyfile(capsule_path, os.path.join(grid_dir, "%dp.png" % raw_appid))
|
||||
elif icon_path and os.path.isfile(icon_path):
|
||||
shutil.copyfile(icon_path, os.path.join(grid_dir, "%dp.png" % raw_appid))
|
||||
header_path = os.path.join(os.path.dirname(icon_path), "header.png") if icon_path else ""
|
||||
if header_path and os.path.isfile(header_path):
|
||||
shutil.copyfile(header_path, os.path.join(grid_dir, "%d.png" % raw_appid))
|
||||
elif icon_path and os.path.isfile(icon_path):
|
||||
shutil.copyfile(icon_path, os.path.join(grid_dir, "%d.png" % raw_appid))
|
||||
hero_path = os.path.join(os.path.dirname(icon_path), "hero.png") if icon_path else ""
|
||||
if hero_path and os.path.isfile(hero_path):
|
||||
shutil.copyfile(hero_path, os.path.join(grid_dir, "%d_hero.png" % raw_appid))
|
||||
logo_path = os.path.join(os.path.dirname(icon_path), "logo.png") if icon_path else ""
|
||||
if logo_path and os.path.isfile(logo_path):
|
||||
shutil.copyfile(logo_path, os.path.join(grid_dir, "%d_logo.png" % raw_appid))
|
||||
except Exception:
|
||||
pass
|
||||
print("%s '%s' (%d shortcut(s) in the library)" % (
|
||||
"added" if created else "updated", name, len(shortcuts)))
|
||||
|
||||
@@ -491,14 +522,29 @@ cp "$srcdir/shaders/"*.spv "$INSTALL_DIR/shaders/"
|
||||
cp "$srcdir/run.sh" "$INSTALL_DIR/run.sh"
|
||||
chmod 0755 "$INSTALL_DIR/run.sh"
|
||||
INSTALLED_SCRIPT=0
|
||||
if [ -n "$script_dir" ] && [ -f "$script_dir/install.sh" ]; then
|
||||
if [ -f "$srcdir/install.sh" ]; then
|
||||
cp "$srcdir/install.sh" "$INSTALL_DIR/install.sh"
|
||||
chmod 0755 "$INSTALL_DIR/install.sh"
|
||||
INSTALLED_SCRIPT=1
|
||||
elif [ -n "$script_dir" ] && [ -f "$script_dir/install.sh" ]; then
|
||||
cp "$script_dir/install.sh" "$INSTALL_DIR/install.sh"
|
||||
chmod 0755 "$INSTALL_DIR/install.sh"
|
||||
INSTALLED_SCRIPT=1
|
||||
fi
|
||||
if [ -n "$VERSION" ]; then
|
||||
if [ -f "$srcdir/VERSION" ]; then
|
||||
cp "$srcdir/VERSION" "$INSTALL_DIR/VERSION"
|
||||
elif [ -n "$VERSION" ]; then
|
||||
printf '%s\n' "$VERSION" > "$INSTALL_DIR/VERSION"
|
||||
fi
|
||||
for asset in icon.png hero.png capsule.png header.png logo.png; do
|
||||
if [ -f "$srcdir/$asset" ]; then
|
||||
cp "$srcdir/$asset" "$INSTALL_DIR/$asset"
|
||||
elif [ -n "$script_dir" ] && [ -f "$script_dir/$asset" ]; then
|
||||
cp "$script_dir/$asset" "$INSTALL_DIR/$asset"
|
||||
elif [ -f "assets/$asset" ]; then
|
||||
cp "assets/$asset" "$INSTALL_DIR/$asset"
|
||||
fi
|
||||
done
|
||||
ok "$(ls -1 "$INSTALL_DIR/shaders" | wc -l | tr -d ' ') shaders, $(du -h "$INSTALL_DIR/pixelvr" 2>/dev/null | cut -f1) binary"
|
||||
|
||||
missing_libs=""
|
||||
|
||||
@@ -37,12 +37,23 @@ cp "${bin}" "${stage}/PixelVRPlayer/pixelvr"
|
||||
cp "${BUILD_DIR}/shaders/"*.spv "${stage}/PixelVRPlayer/shaders/"
|
||||
cp tools/run.sh "${stage}/PixelVRPlayer/run.sh"
|
||||
cp tools/install.sh "${stage}/PixelVRPlayer/install.sh"
|
||||
if [[ -f "tools/Install Pixel VR Player.desktop" ]]; then
|
||||
cp "tools/Install Pixel VR Player.desktop" "${stage}/PixelVRPlayer/Install Pixel VR Player.desktop"
|
||||
fi
|
||||
for asset in icon.png hero.png capsule.png header.png logo.png; do
|
||||
if [[ -f "assets/${asset}" ]]; then
|
||||
cp "assets/${asset}" "${stage}/PixelVRPlayer/${asset}"
|
||||
fi
|
||||
done
|
||||
if [[ -n "${VERSION}" ]]; then
|
||||
printf '%s\n' "${VERSION}" > "${stage}/PixelVRPlayer/VERSION"
|
||||
fi
|
||||
chmod +x "${stage}/PixelVRPlayer/pixelvr" \
|
||||
"${stage}/PixelVRPlayer/run.sh" \
|
||||
"${stage}/PixelVRPlayer/install.sh"
|
||||
if [[ -f "${stage}/PixelVRPlayer/Install Pixel VR Player.desktop" ]]; then
|
||||
chmod +x "${stage}/PixelVRPlayer/Install Pixel VR Player.desktop"
|
||||
fi
|
||||
|
||||
rm -f "${OUT_TAR}"
|
||||
tar -C "${stage}" -czf "${OUT_TAR}" PixelVRPlayer
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Automated in-headset screenshot capture tool for Valve Steam Frame (SteamOS).
|
||||
Uses OpenVR IVRScreenshots via SteamVR's running compositor to capture
|
||||
both 2D preview and stereo VR framebuffers.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import time
|
||||
import argparse
|
||||
import subprocess
|
||||
import base64
|
||||
from datetime import datetime
|
||||
|
||||
DEFAULT_SSH_KEY = os.path.expanduser(r"~\AppData\Local\steamos-devkit\steamos-devkit\devkit_rsa")
|
||||
DEFAULT_HOST = "steamos@192.168.178.61"
|
||||
|
||||
CPP_CODE = r"""#include <iostream>
|
||||
#include <thread>
|
||||
#include <chrono>
|
||||
#include "/opt/steamvr/tools/hellovr_vulkan_linux/src/openvr/headers/openvr.h"
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
if (argc < 3) {
|
||||
std::cerr << "Usage: " << argv[0] << " <preview_png> <stereo_png>" << std::endl;
|
||||
return 1;
|
||||
}
|
||||
const char* preview = argv[1];
|
||||
const char* vr_file = argv[2];
|
||||
|
||||
vr::EVRInitError err = vr::VRInitError_None;
|
||||
vr::IVRSystem* vrSys = vr::VR_Init(&err, vr::VRApplication_Overlay);
|
||||
if (!vrSys || err != vr::VRInitError_None) {
|
||||
std::cerr << "VR_Init failed: " << vr::VR_GetVRInitErrorAsEnglishDescription(err) << std::endl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
vr::IVRScreenshots* vrScreenshots = (vr::IVRScreenshots*)vr::VR_GetGenericInterface("IVRScreenshots_001", &err);
|
||||
if (!vrScreenshots || err != vr::VRInitError_None) {
|
||||
std::cerr << "GetGenericInterface IVRScreenshots_001 failed: " << err << std::endl;
|
||||
vr::VR_Shutdown();
|
||||
return 1;
|
||||
}
|
||||
|
||||
vr::ScreenshotHandle_t handle = 0;
|
||||
vr::EVRScreenshotError scErr = vrScreenshots->TakeStereoScreenshot(&handle, preview, vr_file);
|
||||
if (scErr != vr::VRScreenshotError_None) {
|
||||
std::cerr << "TakeStereoScreenshot failed with error: " << scErr << std::endl;
|
||||
vr::VR_Shutdown();
|
||||
return 1;
|
||||
}
|
||||
std::cout << "Screenshot requested successfully, handle: " << handle << std::endl;
|
||||
|
||||
// Allow compositor frame loop to flush the write
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(1500));
|
||||
|
||||
vr::VR_Shutdown();
|
||||
return 0;
|
||||
}
|
||||
"""
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Capture in-headset screenshots from Valve Steam Frame")
|
||||
parser.add_argument("--host", default=DEFAULT_HOST, help="Steam Frame SSH destination")
|
||||
parser.add_argument("--key", default=DEFAULT_SSH_KEY, help="Path to devkit private key")
|
||||
parser.add_argument("--delay", type=int, default=0, help="Seconds to wait before taking screenshot")
|
||||
parser.add_argument("--out-dir", default="docs/screenshots", help="Directory to save downloaded screenshots")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.delay > 0:
|
||||
print(f"Waiting {args.delay} seconds before capture (put headset on or position UI)...")
|
||||
time.sleep(args.delay)
|
||||
|
||||
b64_cpp = base64.b64encode(CPP_CODE.encode('utf-8')).decode('utf-8')
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
remote_preview = f"/tmp/pixelvr_snap_{timestamp}_preview.png"
|
||||
remote_stereo = f"/tmp/pixelvr_snap_{timestamp}_stereo.png"
|
||||
|
||||
remote_cmd = (
|
||||
f"if [ ! -f /tmp/pixelvr_screenshot_bin ]; then "
|
||||
f" echo '{b64_cpp}' | base64 -d > /tmp/pixelvr_screenshot.cpp && "
|
||||
f" g++ -O2 /tmp/pixelvr_screenshot.cpp -L/opt/steamvr/bin/linuxarm64 -lopenvr_api -Wl,-rpath,/opt/steamvr/bin/linuxarm64 -o /tmp/pixelvr_screenshot_bin ; "
|
||||
f"fi ; "
|
||||
f"/tmp/pixelvr_screenshot_bin {remote_preview} {remote_stereo}"
|
||||
)
|
||||
|
||||
print(f"Triggering screenshot capture on {args.host}...")
|
||||
res = subprocess.run([
|
||||
"ssh", "-i", args.key, "-o", "StrictHostKeyChecking=no", args.host, remote_cmd
|
||||
], capture_output=True, text=True)
|
||||
|
||||
if res.returncode != 0:
|
||||
print("Error capturing screenshot:\n", res.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(res.stdout.strip())
|
||||
|
||||
os.makedirs(args.out_dir, exist_ok=True)
|
||||
local_preview = os.path.join(args.out_dir, f"screenshot_{timestamp}_preview.png")
|
||||
local_stereo = os.path.join(args.out_dir, f"screenshot_{timestamp}_stereo.png")
|
||||
|
||||
print(f"Downloading captured frames to {args.out_dir}...")
|
||||
subprocess.run(["scp", "-i", args.key, "-o", "StrictHostKeyChecking=no", f"{args.host}:{remote_preview}", local_preview], check=True)
|
||||
subprocess.run(["scp", "-i", args.key, "-o", "StrictHostKeyChecking=no", f"{args.host}:{remote_stereo}", local_stereo], check=True)
|
||||
|
||||
print(f"Saved: {local_preview}")
|
||||
print(f"Saved: {local_stereo}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||