mirror of
https://github.com/Pixelplanet/Pixel-VR-Player.git
synced 2026-10-20 20:01:38 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
858293663b | ||
|
|
6f9a9ac6b5 | ||
|
|
ee95d79efe | ||
|
|
e39fd5e77c | ||
|
|
47836de7d8 | ||
|
|
3164a84545 |
No files matched your search
@@ -0,0 +1,88 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Existing v* tag to publish (leave empty for a build-only run)"
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Steam Frame ARM64 release
|
||||
# Native aarch64 runner: no cross-toolchain or device sysroot needed.
|
||||
# Debian 13 (trixie) ships FFmpeg 7, matching the SONAMEs present on the
|
||||
# Steam Frame (libavcodec.so.61, libavutil.so.59, ...).
|
||||
runs-on: ubuntu-24.04-arm
|
||||
container: debian:trixie
|
||||
steps:
|
||||
- name: Install build dependencies
|
||||
run: |
|
||||
apt-get update
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
build-essential cmake ninja-build git ca-certificates pkg-config file \
|
||||
glslang-tools libvulkan-dev \
|
||||
libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libswresample-dev \
|
||||
libpulse-dev libfreetype-dev libsmbclient-dev \
|
||||
libx11-dev libxrandr-dev libxxf86vm-dev libgl-dev
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.tag || github.ref }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Check scripts
|
||||
run: |
|
||||
set -eux
|
||||
sh -n tools/install.sh
|
||||
bash -n tools/package-release.sh
|
||||
bash -n tools/build.sh
|
||||
|
||||
- name: Build
|
||||
run: bash tools/build.sh
|
||||
|
||||
- name: Package
|
||||
run: |
|
||||
set -eux
|
||||
tag="${{ inputs.tag || github.ref_name }}"
|
||||
bash tools/package-release.sh build pixelvr-linux-arm64.tar.gz "${tag}"
|
||||
cp pixelvr-linux-arm64.tar.gz "pixelvr-linux-arm64-${tag}.tar.gz"
|
||||
sha256sum pixelvr-linux-arm64.tar.gz > pixelvr-linux-arm64.tar.gz.sha256
|
||||
sha256sum "pixelvr-linux-arm64-${tag}.tar.gz" > "pixelvr-linux-arm64-${tag}.tar.gz.sha256"
|
||||
|
||||
- name: Verify artifact
|
||||
run: |
|
||||
file build/bin/pixelvr
|
||||
file build/bin/pixelvr | grep -q 'ARM aarch64'
|
||||
tar -tzf pixelvr-linux-arm64.tar.gz | grep -q '^PixelVRPlayer/run.sh$'
|
||||
tar -tzf pixelvr-linux-arm64.tar.gz | grep -q '^PixelVRPlayer/install.sh$'
|
||||
tar -tzf pixelvr-linux-arm64.tar.gz | grep -q '^PixelVRPlayer/VERSION$'
|
||||
tar -tzf pixelvr-linux-arm64.tar.gz | grep -q '/shaders/thumb.frag.spv$'
|
||||
sha256sum -c pixelvr-linux-arm64.tar.gz.sha256
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: pixelvr-linux-arm64
|
||||
path: |
|
||||
pixelvr-linux-arm64.tar.gz
|
||||
pixelvr-linux-arm64.tar.gz.sha256
|
||||
pixelvr-linux-arm64-v*.tar.gz
|
||||
|
||||
- name: Publish GitHub release
|
||||
if: startsWith(github.ref, 'refs/tags/v') || inputs.tag != ''
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ inputs.tag || github.ref_name }}
|
||||
generate_release_notes: true
|
||||
files: |
|
||||
pixelvr-linux-arm64.tar.gz
|
||||
pixelvr-linux-arm64.tar.gz.sha256
|
||||
pixelvr-linux-arm64-v*.tar.gz
|
||||
pixelvr-linux-arm64-v*.tar.gz.sha256
|
||||
@@ -5,6 +5,8 @@
|
||||
/install/
|
||||
/dist/
|
||||
/sysroot/
|
||||
# Release artifacts are attached to GitHub releases, not committed
|
||||
/*.tar.gz
|
||||
*.o
|
||||
*.obj
|
||||
*.a
|
||||
|
||||
+3
-1
@@ -108,7 +108,8 @@ set(PIXELVR_SHADERS
|
||||
shaders/ui.vert
|
||||
shaders/ui.frag
|
||||
shaders/text.vert
|
||||
shaders/text.frag)
|
||||
shaders/text.frag
|
||||
shaders/thumb.frag)
|
||||
pixelvr_compile_shaders(pixelvr_shaders
|
||||
OUTPUT_DIR "${CMAKE_BINARY_DIR}/shaders"
|
||||
SOURCES ${PIXELVR_SHADERS})
|
||||
@@ -124,6 +125,7 @@ add_library(pixelvr_core STATIC
|
||||
src/gfx/vulkan_renderer.cpp
|
||||
src/gfx/gltf_mesh.cpp
|
||||
src/ui/settings_menu.cpp
|
||||
src/ui/thumbnail_cache.cpp
|
||||
src/app/app.cpp)
|
||||
|
||||
target_include_directories(pixelvr_core PUBLIC
|
||||
|
||||
@@ -20,19 +20,41 @@ A native VR video player for the **Valve Steam Frame**, playing flat 2D, 180°,
|
||||
|
||||
## Install (prebuilt release)
|
||||
|
||||
Download `pixelvr-linux-arm64.tar.gz` from the
|
||||
[Releases](../../releases) page and copy it to your Steam Frame, then:
|
||||
One command, from a terminal on the headset (Desktop Mode or over SSH):
|
||||
|
||||
```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.
|
||||
|
||||
Launch **Pixel VR Player** from your library, or directly:
|
||||
|
||||
```bash
|
||||
tar -xzf pixelvr-linux-arm64.tar.gz -C ~
|
||||
chmod +x ~/PixelVRPlayer/run.sh
|
||||
~/PixelVRPlayer/run.sh # opens the in-headset file browser
|
||||
~/PixelVRPlayer/run.sh /path/clip.mp4 # …or auto-play a specific clip
|
||||
```
|
||||
|
||||
To launch it from the headset library, add `~/PixelVRPlayer/run.sh` as a
|
||||
**Non-Steam Game** (Steam ▸ Add a Non-Steam Game). SteamVR must be running; the
|
||||
app logs to `~/pixelvr-run.log`.
|
||||
Installer options:
|
||||
|
||||
| Option | Effect |
|
||||
| --- | --- |
|
||||
| `--dir DIR` | install somewhere other than `~/PixelVRPlayer` |
|
||||
| `--version vX.Y.Z` | install a specific release instead of the latest |
|
||||
| `--no-steam` | skip the Steam library entry |
|
||||
| `--restart-steam` | close Steam first, then reopen it so the entry appears at once |
|
||||
| `--uninstall` | remove the app and its Steam shortcut |
|
||||
|
||||
Re-run the same command to upgrade. Settings (`~/.config/pixelvr`) and cached
|
||||
thumbnails (`~/.cache/pixelvr`) live outside the install directory and are kept.
|
||||
|
||||
**Offline install:** download `pixelvr-linux-arm64.tar.gz` from the
|
||||
[Releases](../../releases) page, copy it to the headset, extract it and run
|
||||
`./install.sh` from the extracted folder — or use `run.sh` directly. The app logs
|
||||
to `~/pixelvr-run.log`.
|
||||
|
||||
Requirements: the SteamVR OpenXR runtime (SteamOS), Vulkan, and FFmpeg with the
|
||||
V4L2 M2M decoders. For zero-copy dma-buf decoding, build the patched FFmpeg into
|
||||
@@ -40,6 +62,30 @@ V4L2 M2M decoders. For zero-copy dma-buf decoding, build the patched FFmpeg into
|
||||
back to the system FFmpeg with CPU colour conversion.
|
||||
|
||||
|
||||
## Controls
|
||||
|
||||
The laser pointer and the control bar are **separate** input paths, so the bar
|
||||
stays usable while a panel is open.
|
||||
|
||||
| Input | Action |
|
||||
| --- | --- |
|
||||
| **Trigger** | Point and click: open the entry under the laser, or press a control-bar button |
|
||||
| **A** | Open the highlighted entry — folder navigates, video plays / streams. Outside the browser it is play / pause |
|
||||
| **X** / **Y** | Download the highlighted entry (folders confirm first, showing their size) |
|
||||
| **B** | Open or close the file browser; also closes a panel and cancels the download dialog |
|
||||
| **Grip** | Toggle **that hand's** laser pointer on/off. Turn one off to navigate with the thumbstick or d-pad without the beam interfering, and to choose which controller points |
|
||||
| Thumbstick | Move the highlight in a panel; left/right seeks while playing |
|
||||
| **Bumper** | Recenter the view in front of you |
|
||||
|
||||
The control bar has a **Download** button (arrow) that downloads the entry
|
||||
highlighted in the browser, and a **Downloads** button (list icon) that opens the
|
||||
downloads manager — where you can pause, resume, stop and clear jobs, then play a
|
||||
finished file or open a finished folder. Opening the downloads manager replaces
|
||||
the file browser rather than stacking on top of it.
|
||||
|
||||
All button bindings can be changed in **Settings ▸ Controls** (press-to-bind).
|
||||
|
||||
|
||||
## Repository layout
|
||||
|
||||
```
|
||||
@@ -123,6 +169,15 @@ aarch64 cross (for the Steam Frame):
|
||||
./tools/build.sh --aarch64 # configures + builds build-aarch64/
|
||||
```
|
||||
|
||||
Release builds are automated: `.github/workflows/release.yml` builds natively on
|
||||
an ARM64 runner in a Debian 13 container (FFmpeg 7, matching the headset's
|
||||
SONAMEs) and attaches `pixelvr-linux-arm64.tar.gz` to each `v*` tag's release.
|
||||
To package a local build by hand:
|
||||
|
||||
```bash
|
||||
./tools/package-release.sh build-aarch64 pixelvr-linux-arm64.tar.gz
|
||||
```
|
||||
|
||||
## Deploy to Steam Frame
|
||||
|
||||
See the full walkthrough in
|
||||
|
||||
@@ -4,8 +4,25 @@ This guide covers how to get the built app running on the headset, the available
|
||||
options, and which one to pick.
|
||||
|
||||
**What you're shipping:** a single self-contained ARM64 binary
|
||||
(`build-aarch64/bin/pixelvr`, ~11 MB, static OpenXR loader) plus four SPIR-V
|
||||
shaders. That's it — no `libopenxr_loader.so` or other bundled libraries.
|
||||
(`build-aarch64/bin/pixelvr`, ~11 MB, static OpenXR loader) plus SPIR-V shaders
|
||||
and a shell launcher. That's it — no `libopenxr_loader.so` or other bundled
|
||||
libraries.
|
||||
|
||||
---
|
||||
|
||||
## Just installing a release (no devkit, no build)
|
||||
|
||||
End users do not need any of the steps below. From a terminal on the headset
|
||||
(Desktop Mode or over 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.
|
||||
|
||||
---
|
||||
|
||||
@@ -20,6 +37,9 @@ shaders. That's it — no `libopenxr_loader.so` or other bundled libraries.
|
||||
|
||||
# 2b. Or stage a clean folder for the GUI Devkit Client:
|
||||
./tools/stage.sh # -> dist/PixelVRPlayer/
|
||||
|
||||
# 2c. Or build the release tarball end users install from:
|
||||
./tools/package-release.sh build-aarch64 pixelvr-linux-arm64.tar.gz v1.3.0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
# Pixel VR Player: Implementation Plan for Modern Spatial UI
|
||||
|
||||
This document details the concrete implementation plan to bring **Mockup #1 (Spatial Card Media Browser)** and **Mockup #2 (Floating Cinema Transport Dock)** into Pixel VR Player, including the **Flat vs. Curved Cinema Screen** engine and **180°/360° VR contextual adaptations**, without breaking existing hardware-accelerated playback or network functionality.
|
||||
|
||||
---
|
||||
|
||||
## 1. Architectural Overview & Design Decisions
|
||||
|
||||
### Selected Concepts
|
||||
- **File Browser:** **Concept #1 (Spatial Media Browser)** — 3x2 / 4x2 responsive card grid, 16:9 thumbnail previews, format badges (`VR180 3D`, `4K HEVC`), breadcrumb path bar, source tabs (`Local`, `SMB Shares`, `Downloads`), left navigation rail.
|
||||
- **Video Controls:** **Concept #2 (Floating Transport Dock)** — Frosted glassmorphic pill dock, prominent glowing central Play/Pause hero button, wide tactile scrubber bar with buffered track & hover timestamp, quick format pill (`VR 180° SBS`), audio track pill (`ENG 5.1`), screen distance/tilt controls, and download status badge.
|
||||
- **Cinema Screen:** **Parametric Flat ↔ Curved Screen Engine** — Dynamically adjustable curvature (from $0.0$ flat plane to $1.0$ IMAX-style cylindrical wrap around the viewer).
|
||||
- **180° / 360° VR Adaptation:** Context-aware HUD positioning, torso/yaw "lazy follow", auto-hide fading, and 180°-specific controls (Horizon Tilt & FOV Zoom).
|
||||
|
||||
### Visual Mockups
|
||||
|
||||
#### Mockup #1: Spatial Media Browser (Card Grid & Navigation Rail)
|
||||

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

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

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

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

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

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

|
||||
|
||||
- **Location:** Floating at `y = -0.65m, z = -2.2m`, tilted 20° up toward viewer.
|
||||
- **Scrubber Bar (Top Row):**
|
||||
- Full-width interactive timeline scrubber with elapsed time (`18:24`) and remaining (`-41:36`).
|
||||
- Laser hover reveals a floating mini-thumbnail tooltip at the seek position.
|
||||
- **Center Transport Cluster:**
|
||||
- 4 primary circular buttons with generous touch targets:
|
||||
`[Stop]` `[Seek -10s]` `(Play/Pause)` `[Seek +10s]`.
|
||||
- **Left Flank:**
|
||||
- `[HUB]` button (opens the Unified Spatial Hub).
|
||||
- `[RECENTER]` button (yaw/pitch reset).
|
||||
- **Right Flank:**
|
||||
- `[FORMAT]` toggle pill (cycles 2D, 3D SBS, VR180, 360).
|
||||
- `[AUDIO]` track selector pill (greys out to `DEFAULT` if only 1 audio stream exists).
|
||||
- `[SCREEN]` toggle pill (toggles Flat / Curved geometry).
|
||||
|
||||
---
|
||||
|
||||
## 4. Root Causes & Direct Fixes for Previous Issues
|
||||
|
||||
### Issue A: Thumbnail Generation Failure on Headset
|
||||
- **Root Cause Identified:**
|
||||
1. `ThumbnailCache::workerLoop` ran `ffmpeg` without `-nostdin` or with `-threads 4` inside `std::system`. When ffmpeg prompts for stdin or encounters an existing file without `-y` in certain subshells, it fails.
|
||||
2. Thumbnail request targets in `app.cpp` passed `/home/steamos/Downloads/.../sample.mkv` but some MKV/MP4 files had deep directories where `ffmpeg` needed `-ss 00:00:05` before `-i` and `-nostdin` to prevent process suspension.
|
||||
3. The descriptor set allocation for thumbnail slots was previously leaking or getting overwritten.
|
||||
- **Solution for Implementation:**
|
||||
- Run ffmpeg with: `ffmpeg -nostdin -y -v error -ss 00:00:05 -i "<file>" -vframes 1 -s 256x144 -f rawvideo -pix_fmt rgba "<cacheFile>"`.
|
||||
- Add fallback to `-ss 00:00:00`.
|
||||
- Pre-allocate a dedicated `VkDescriptorPool` of 6 persistent image sampler sets exclusively for the 6 card slots so no descriptor reallocation occurs during runtime.
|
||||
|
||||
### Issue B: Inconsistent Text Sizes Across Buttons
|
||||
- **Root Cause Identified:**
|
||||
The previous `drawFit` function dynamically shrunk the font size of each button individually based on the length of its text. This caused short labels like `HOME` to look huge while longer labels looked tiny and misaligned.
|
||||
- **Solution for Implementation:**
|
||||
- Fix button width and font scale globally to `kStandardFontSize`.
|
||||
- If a label exceeds the generous button width, wrap or cleanly truncate with an ellipsis (`...`), keeping all button font scales strictly identical across the interface.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 642 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 549 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 556 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 465 KiB |
+39
-12
@@ -1,26 +1,53 @@
|
||||
#version 450
|
||||
|
||||
// Textured video screen: a 16:9 quad whose distance, yaw and aspect are applied
|
||||
// by the model matrix on the CPU. Geometry and UVs are hardcoded and indexed by
|
||||
// gl_VertexIndex (no vertex buffer).
|
||||
// Textured video screen: a 16:9 screen with parametric curvature (Flat -> Curved).
|
||||
// 64 horizontal segments (384 vertices) are procedurally evaluated from gl_VertexIndex.
|
||||
// pc.stereo.w carries the curvature factor in [0.0, 1.0] (0 = flat, >0 = curved arc).
|
||||
|
||||
layout(push_constant) uniform Push {
|
||||
mat4 mvp;
|
||||
vec4 conversion;
|
||||
vec4 range;
|
||||
vec4 stereo;
|
||||
vec4 stereo; // x: stereoMode, y: viewIndex, z: swapEyes, w: curvature
|
||||
} pc;
|
||||
|
||||
layout(location = 0) out vec2 vUV;
|
||||
|
||||
void main() {
|
||||
vec2 positions[6] = vec2[](
|
||||
vec2(-1.6, 0.9), vec2(-1.6, -0.9), vec2( 1.6, -0.9),
|
||||
vec2(-1.6, 0.9), vec2( 1.6, -0.9), vec2( 1.6, 0.9));
|
||||
vec2 uvs[6] = vec2[](
|
||||
vec2(0.0, 0.0), vec2(0.0, 1.0), vec2(1.0, 1.0),
|
||||
vec2(0.0, 0.0), vec2(1.0, 1.0), vec2(1.0, 0.0));
|
||||
const int kSegments = 64;
|
||||
int seg = gl_VertexIndex / 6;
|
||||
int vid = gl_VertexIndex % 6;
|
||||
|
||||
gl_Position = pc.mvp * vec4(positions[gl_VertexIndex], 0.0, 1.0);
|
||||
vUV = uvs[gl_VertexIndex];
|
||||
float u0 = float(seg) / float(kSegments);
|
||||
float u1 = float(seg + 1) / float(kSegments);
|
||||
|
||||
// Quad vertex coordinates for this segment
|
||||
float u = u0;
|
||||
float y = 0.9;
|
||||
if (vid == 1) { u = u0; y = -0.9; }
|
||||
else if (vid == 2) { u = u1; y = -0.9; }
|
||||
else if (vid == 3) { u = u0; y = 0.9; }
|
||||
else if (vid == 4) { u = u1; y = -0.9; }
|
||||
else if (vid == 5) { u = u1; y = 0.9; }
|
||||
|
||||
vUV = vec2(u, (0.9 - y) / 1.8);
|
||||
|
||||
// Nominal screen width = 3.2 (-1.6 to +1.6)
|
||||
const float kWidth = 3.2;
|
||||
float curv = clamp(pc.stereo.w, 0.0, 1.0);
|
||||
|
||||
float x = (u - 0.5) * kWidth;
|
||||
float z = 0.0;
|
||||
if (curv > 0.005) {
|
||||
// Pure cylindrical arc of constant arc length kWidth.
|
||||
// At curv=1.0, total wrap angle is ~100 degrees (1.75 rad) around the viewer.
|
||||
float totalAngle = curv * 1.75;
|
||||
float radius = kWidth / totalAngle;
|
||||
float theta = (u - 0.5) * totalAngle;
|
||||
x = radius * sin(theta);
|
||||
z = radius * (1.0 - cos(theta));
|
||||
}
|
||||
|
||||
gl_Position = pc.mvp * vec4(x, y, z, 1.0);
|
||||
}
|
||||
|
||||
+13
-4
@@ -12,10 +12,19 @@ layout(push_constant) uniform Push {
|
||||
layout(location = 0) in vec2 vUV;
|
||||
layout(location = 0) out vec4 outColor;
|
||||
|
||||
vec3 toLinear(vec3 srgb) {
|
||||
return pow(max(srgb, vec3(0.0)), vec3(2.2));
|
||||
}
|
||||
|
||||
void main() {
|
||||
// Signed-distance text: a crisp ~1px edge in screen space so it stays sharp
|
||||
// at any size (a coverage-bitmap fallback also reads acceptably here).
|
||||
// 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.
|
||||
float d = texture(fontAtlas, vUV).r;
|
||||
float a = clamp((d - 0.5) / max(fwidth(d), 0.0001) + 0.5, 0.0, 1.0);
|
||||
outColor = vec4(pc.color.rgb, pc.color.a * a);
|
||||
float w = max(length(vec2(dFdx(d), dFdy(d))) * 0.92, 0.025);
|
||||
float a = smoothstep(0.465 - w, 0.465 + w, d);
|
||||
if (a <= 0.002) {
|
||||
discard;
|
||||
}
|
||||
outColor = vec4(toLinear(pc.color.rgb), pc.color.a * a);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
#version 450
|
||||
|
||||
layout(binding = 0) uniform sampler2D thumbTex;
|
||||
|
||||
layout(push_constant) uniform Push {
|
||||
mat4 mvp;
|
||||
vec4 color;
|
||||
vec4 rect; // x, y (bottom-left), w, h in panel-local units
|
||||
vec4 uv; // u0, v0, u1, v1
|
||||
} pc;
|
||||
|
||||
layout(location = 0) in vec2 vUV;
|
||||
layout(location = 0) out vec4 outColor;
|
||||
|
||||
void main() {
|
||||
vec4 col = texture(thumbTex, vUV);
|
||||
|
||||
// Exact metric rounded rectangle SDF matching panel styling with screen-space AA
|
||||
vec2 p = abs(vUV - 0.5) * pc.rect.zw;
|
||||
vec2 b = 0.5 * pc.rect.zw - vec2(0.018);
|
||||
vec2 q = max(p - b, 0.0);
|
||||
float dist = length(q) - 0.018;
|
||||
float aa = max(length(vec2(dFdx(dist), dFdy(dist))) * 1.15, 0.0025);
|
||||
float alpha = 1.0 - smoothstep(-aa, aa, dist);
|
||||
if (alpha <= 0.002) {
|
||||
discard;
|
||||
}
|
||||
|
||||
// Thumbnail pixels are sRGB; convert to linear for the VK_FORMAT_*_SRGB framebuffer
|
||||
vec3 linRgb = pow(max(col.rgb, vec3(0.0)), vec3(2.2));
|
||||
outColor = vec4(linRgb * pc.color.rgb, col.a * pc.color.a * alpha);
|
||||
}
|
||||
+56
-8
@@ -122,24 +122,72 @@ void main() {
|
||||
vec2(0.36, 0.30), vec2(0.0, -0.28)) - 0.03;
|
||||
float base = sdRoundedBox(p - vec2(0.0, -0.62), vec2(0.52, 0.08), 0.03);
|
||||
d = min(min(shaft, head), base);
|
||||
} else if (shape == 13) {
|
||||
// Shape 13: List / queue (three bars with leading dots) for the
|
||||
// downloads window, so it is distinct from the plain download arrow.
|
||||
float b1 = sdRoundedBox(p - vec2(0.10, 0.46), vec2(0.40, 0.07), 0.05);
|
||||
float b2 = sdRoundedBox(p - vec2(0.10, 0.00), vec2(0.40, 0.07), 0.05);
|
||||
float b3 = sdRoundedBox(p - vec2(0.10, -0.46), vec2(0.40, 0.07), 0.05);
|
||||
float p1 = sdRoundedBox(p - vec2(-0.50, 0.46), vec2(0.10, 0.10), 0.05);
|
||||
float p2 = sdRoundedBox(p - vec2(-0.50, 0.00), vec2(0.10, 0.10), 0.05);
|
||||
float p3 = sdRoundedBox(p - vec2(-0.50, -0.46), vec2(0.10, 0.10), 0.05);
|
||||
d = min(min(min(b1, b2), min(b3, p1)), min(p2, p3));
|
||||
} else if (shape == 14) {
|
||||
// Shape 14: Hero Glow Ring (circular outline for primary buttons)
|
||||
float r = length(p);
|
||||
d = abs(r - 0.72) - 0.08;
|
||||
} else if (shape == 15) {
|
||||
// Shape 15: Audio Speaker icon (cone + sound wave)
|
||||
float box = sdRoundedBox(p - vec2(-0.32, 0.0), vec2(0.12, 0.18), 0.03);
|
||||
float cone = sdTriangle(p, vec2(-0.20, 0.18), vec2(0.16, 0.45), vec2(-0.20, -0.18)) - 0.02;
|
||||
float cone2 = sdTriangle(p, vec2(-0.20, -0.18), vec2(0.16, -0.45), vec2(0.16, 0.45)) - 0.02;
|
||||
float rArc = length(p - vec2(-0.05, 0.0));
|
||||
float arc1 = max(abs(rArc - 0.46) - 0.06, -p.x + 0.10);
|
||||
float arc2 = max(abs(rArc - 0.68) - 0.06, -p.x + 0.22);
|
||||
d = min(min(box, min(cone, cone2)), min(arc1, arc2));
|
||||
} else if (shape == 16) {
|
||||
// Shape 16: VR Headset / Display goggles
|
||||
float gBody = sdRoundedBox(p, vec2(0.55, 0.30), 0.14);
|
||||
float nose = sdTriangle(p - vec2(0.0, -0.30), vec2(-0.16, -0.08), vec2(0.16, -0.08), vec2(0.0, 0.12)) - 0.04;
|
||||
float strapL = sdRoundedBox(p - vec2(-0.62, 0.0), vec2(0.08, 0.10), 0.03);
|
||||
float strapR = sdRoundedBox(p - vec2(0.62, 0.0), vec2(0.08, 0.10), 0.03);
|
||||
d = max(min(gBody, min(strapL, strapR)), -nose);
|
||||
} else if (shape == 17) {
|
||||
// Shape 17: Curved Cinema Display icon
|
||||
float rScreen = length(p - vec2(0.0, 1.3));
|
||||
float arcDisp = abs(rScreen - 1.62) - 0.10;
|
||||
float cutX = abs(p.x) - 0.58;
|
||||
float cutY = abs(p.y) - 0.32;
|
||||
d = max(arcDisp, max(cutX, cutY));
|
||||
} else if (shape == 18) {
|
||||
// Shape 18: Chevron (>) for breadcrumbs & pagination
|
||||
float c1 = sdSegment(p, vec2(-0.22, 0.40), vec2(0.24, 0.0)) - 0.08;
|
||||
float c2 = sdSegment(p, vec2(0.24, 0.0), vec2(-0.22, -0.40)) - 0.08;
|
||||
d = min(c1, c2);
|
||||
} else if (shape == 19) {
|
||||
// Shape 19: Home / Library icon
|
||||
float roof = sdTriangle(p - vec2(0.0, 0.12), vec2(-0.52, 0.0), vec2(0.52, 0.0), vec2(0.0, 0.42)) - 0.04;
|
||||
float baseH = sdRoundedBox(p - vec2(0.0, -0.20), vec2(0.35, 0.26), 0.04);
|
||||
d = min(roof, baseH);
|
||||
} else {
|
||||
d = sdRoundedBox(p, vec2(1.0), 0.0);
|
||||
}
|
||||
|
||||
float aa = fwidth(d);
|
||||
if (aa < 1e-4) aa = 0.015;
|
||||
float fillAlpha = 1.0 - smoothstep(-aa * 0.5, aa * 0.5, d);
|
||||
float aa = max(length(vec2(dFdx(d), dFdy(d))) * 1.15, 0.003);
|
||||
float fillAlpha = 1.0 - smoothstep(-aa, aa, d);
|
||||
|
||||
if (fillAlpha <= 0.0) {
|
||||
if (fillAlpha <= 0.002) {
|
||||
discard;
|
||||
}
|
||||
|
||||
vec4 finalColor = pc.color;
|
||||
vec4 fillLin = vec4(pow(max(pc.color.rgb, vec3(0.0)), vec3(2.2)), pc.color.a);
|
||||
vec4 finalColor = fillLin;
|
||||
if (hasBorder) {
|
||||
float borderWidth = pc.params.z;
|
||||
float borderWidth = max(pc.params.z, aa * 1.6);
|
||||
float innerD = d + borderWidth;
|
||||
float borderAlpha = 1.0 - smoothstep(-aa * 0.5, aa * 0.5, innerD);
|
||||
finalColor = mix(pc.borderColor, pc.color, borderAlpha);
|
||||
float borderAlpha = 1.0 - smoothstep(-aa, aa, innerD);
|
||||
vec4 bordLin = vec4(pow(max(pc.borderColor.rgb, vec3(0.0)), vec3(2.2)), pc.borderColor.a);
|
||||
finalColor = mix(bordLin, fillLin, borderAlpha);
|
||||
}
|
||||
|
||||
finalColor.a *= fillAlpha;
|
||||
|
||||
+501
-122
@@ -6,12 +6,15 @@
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
namespace pixelvr {
|
||||
|
||||
namespace {
|
||||
@@ -151,7 +154,14 @@ bool App::init(const std::string& mediaPath) {
|
||||
|
||||
#ifdef PIXELVR_HAVE_MEDIA
|
||||
if (!mediaPath.empty()) {
|
||||
loadMedia(mediaPath);
|
||||
std::error_code ec;
|
||||
if (std::filesystem::is_directory(mediaPath, ec)) {
|
||||
#ifdef PIXELVR_HAVE_FREETYPE
|
||||
browser_.openPath(mediaPath);
|
||||
#endif
|
||||
} else {
|
||||
loadMedia(mediaPath);
|
||||
}
|
||||
}
|
||||
#else
|
||||
(void)mediaPath;
|
||||
@@ -349,20 +359,39 @@ void App::updateStats() {
|
||||
|
||||
int App::navStep(float axisY, std::chrono::steady_clock::time_point now) {
|
||||
using namespace std::chrono_literals;
|
||||
if (axisY > 0.6f || axisY < -0.6f) {
|
||||
if (axisY > 0.38f || axisY < -0.38f) {
|
||||
const int dir = (axisY > 0.0f) ? -1 : 1; // push up = toward the top row
|
||||
if (navArmed_) { // first press: step now, then pause before repeating
|
||||
navArmed_ = false;
|
||||
navNextRepeat_ = now + 400ms;
|
||||
navNextRepeat_ = now + 350ms;
|
||||
return dir;
|
||||
}
|
||||
if (now >= navNextRepeat_) { // held down: auto-repeat
|
||||
navNextRepeat_ = now + 110ms;
|
||||
navNextRepeat_ = now + 120ms;
|
||||
return dir;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if (axisY > -0.3f && axisY < 0.3f) navArmed_ = true; // released: re-arm
|
||||
if (axisY > -0.20f && axisY < 0.20f) navArmed_ = true; // released: re-arm
|
||||
return 0;
|
||||
}
|
||||
|
||||
int App::navStepX(float axisX, std::chrono::steady_clock::time_point now) {
|
||||
using namespace std::chrono_literals;
|
||||
if (axisX > 0.38f || axisX < -0.38f) {
|
||||
const int dir = (axisX > 0.0f) ? 1 : -1; // push right = next col, push left = prev col
|
||||
if (navArmedX_) {
|
||||
navArmedX_ = false;
|
||||
navNextRepeatX_ = now + 350ms;
|
||||
return dir;
|
||||
}
|
||||
if (now >= navNextRepeatX_) {
|
||||
navNextRepeatX_ = now + 140ms;
|
||||
return dir;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if (axisX > -0.20f && axisX < 0.20f) navArmedX_ = true;
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -427,6 +456,28 @@ 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.
|
||||
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()) {
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
bool interacted = false;
|
||||
bool browserOpen = false;
|
||||
bool settingsOpen = false;
|
||||
@@ -463,8 +514,13 @@ int App::run() {
|
||||
// Live settings + place the screen in front on first head tracking.
|
||||
renderer_.setSwapEyes(settings_.swapEyes());
|
||||
renderer_.setDrawModels(settings_.drawModels());
|
||||
renderer_.setUiAppearance(settings_.uiTextScale(),
|
||||
settings_.uiTextColorIdx(),
|
||||
settings_.uiAccentColorIdx(),
|
||||
settings_.uiDistance());
|
||||
if (!didInitialRecenter_ && in.headValid) {
|
||||
renderer_.recenterScreen(in.headPos, in.headForward);
|
||||
renderer_.setScreenCurvature(settings_.screenCurvature());
|
||||
didInitialRecenter_ = true;
|
||||
}
|
||||
|
||||
@@ -558,78 +614,84 @@ int App::run() {
|
||||
!browser_.downloadActive() &&
|
||||
browser_.hasDownloads() && dlRecent,
|
||||
!browser_.downloadActive() && dlFailed);
|
||||
// Downloads window: job list plus action buttons at the bottom.
|
||||
//Downloads window: job list plus action buttons at the bottom.
|
||||
std::vector<std::string> drows;
|
||||
std::vector<std::string> dbtns;
|
||||
if (browser_.downloadActive()) {
|
||||
dbtns.push_back(browser_.downloadPaused() ? "Resume" : "Pause");
|
||||
dbtns.push_back("Stop");
|
||||
dbtns.push_back(browser_.downloadPaused() ? "Resume All" : "Pause All");
|
||||
dbtns.push_back("Cancel Active");
|
||||
}
|
||||
dbtns.push_back("Clear finished");
|
||||
dbtns.push_back("Clear Completed");
|
||||
std::vector<VulkanRenderer::DownloadItem> djList;
|
||||
int activeDlCount = 0;
|
||||
{
|
||||
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;
|
||||
std::string nm = j.name;
|
||||
if (nm.size() > 30) nm = nm.substr(0, 29) + "~";
|
||||
char buf[192];
|
||||
VulkanRenderer::DownloadItem di;
|
||||
di.label = j.name;
|
||||
di.state = j.state;
|
||||
di.progress = (j.total > 0.0) ? static_cast<float>(static_cast<double>(j.bytes) / static_cast<double>(j.total)) : (j.state == 1 ? 1.0f : 0.0f);
|
||||
if (j.state == 0) {
|
||||
const int pct =
|
||||
j.total > 0 ? static_cast<int>(j.bytes * 100 / j.total) : 0;
|
||||
std::snprintf(buf, sizeof(buf), "%s %d%% %.0f/%.0f MB",
|
||||
nm.c_str(), pct, j.bytes / 1048576.0,
|
||||
j.total / 1048576.0);
|
||||
const double speedMb = browser_.downloadSpeed() / (1024.0 * 1024.0);
|
||||
std::string etaStr = "";
|
||||
if (j.total > 0 && !browser_.downloadPaused() && browser_.downloadSpeed() >= 100.0 * 1024.0) {
|
||||
double eta = static_cast<double>(j.total - j.bytes) / browser_.downloadSpeed();
|
||||
if (eta < 0.0) eta = 0.0;
|
||||
const int secs = static_cast<int>(eta);
|
||||
char ebuf[32];
|
||||
std::snprintf(ebuf, sizeof(ebuf), " \xB7 ETA %02d:%02d", secs / 60, secs % 60);
|
||||
etaStr = ebuf;
|
||||
} else if (browser_.downloadPaused()) {
|
||||
etaStr = " \xB7 [PAUSED]";
|
||||
}
|
||||
char det[160];
|
||||
if (speedMb > 0.05) {
|
||||
std::snprintf(det, sizeof(det), "Downloading \xB7 %.1f / %.1f MB (%d%%) \xB7 %.1f MB/s%s",
|
||||
j.bytes / 1048576.0, j.total / 1048576.0, pct, speedMb, etaStr.c_str());
|
||||
} else {
|
||||
std::snprintf(det, sizeof(det), "Downloading \xB7 %.1f / %.1f MB (%d%%)%s",
|
||||
j.bytes / 1048576.0, j.total / 1048576.0, pct, etaStr.c_str());
|
||||
}
|
||||
di.detail = det;
|
||||
std::snprintf(buf, sizeof(buf), "%s %s", nm.c_str(), det);
|
||||
} else if (j.state == 1) {
|
||||
std::snprintf(buf, sizeof(buf), "%s done", nm.c_str());
|
||||
if (j.total > 0) {
|
||||
char cdet[96];
|
||||
std::snprintf(cdet, sizeof(cdet), "Completed \xB7 %.1f MB \xB7 Stored in %s",
|
||||
j.total / 1048576.0, settings_.downloadDir().c_str());
|
||||
di.detail = cdet;
|
||||
} else {
|
||||
di.detail = "Completed \xB7 Stored locally";
|
||||
}
|
||||
} else if (j.state == 3) {
|
||||
std::snprintf(buf, sizeof(buf), "%s stopped", nm.c_str());
|
||||
di.detail = "Paused / Stopped";
|
||||
} else {
|
||||
std::snprintf(buf, sizeof(buf), "%s failed", nm.c_str());
|
||||
di.detail = "Transfer Failed";
|
||||
}
|
||||
drows.push_back(buf);
|
||||
djList.push_back(std::move(di));
|
||||
}
|
||||
}
|
||||
const bool dlWin = downloadsWindow_ && !browser_.isOpen() &&
|
||||
!settings_.isOpen() && pendingDl_.empty();
|
||||
renderer_.setActiveDownloadCount(activeDlCount);
|
||||
const bool dlWin = downloadsWindow_ && !settings_.isOpen() &&
|
||||
pendingDl_.empty();
|
||||
renderer_.setDownloadsModal(false);
|
||||
renderer_.setDownloads(
|
||||
dlWin, "Downloads", drows,
|
||||
browser_.downloadActive()
|
||||
? browser_.downloadText()
|
||||
: "Point + Trigger to select B: close",
|
||||
dbtns);
|
||||
if (dlWin && in.select) {
|
||||
const int bi = renderer_.downloadsButtonHovered();
|
||||
if (bi >= 0) {
|
||||
if (browser_.downloadActive()) {
|
||||
if (bi == 0) browser_.togglePause();
|
||||
else if (bi == 1) browser_.stopDownload();
|
||||
else if (bi == 2) browser_.clearFinished();
|
||||
} else if (bi == 0) {
|
||||
browser_.clearFinished();
|
||||
}
|
||||
interacted = true;
|
||||
} 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()) {
|
||||
const auto& j = jobs[static_cast<std::size_t>(hov)];
|
||||
if (j.isDir) {
|
||||
// A finished folder opens in the file browser.
|
||||
browser_.openPath(j.localPath);
|
||||
downloadsWindow_ = false;
|
||||
} else {
|
||||
#ifdef PIXELVR_HAVE_MEDIA
|
||||
loadMedia(j.localPath);
|
||||
downloadsWindow_ = false;
|
||||
#endif
|
||||
}
|
||||
interacted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
: "Trigger: Play Completed | B: Close Hub",
|
||||
dbtns, djList);
|
||||
|
||||
// Folder-download confirmation: a modal with the size and two buttons.
|
||||
if (!pendingDl_.empty()) {
|
||||
@@ -670,33 +732,147 @@ int App::run() {
|
||||
} else {
|
||||
renderer_.setDownloadsModal(false);
|
||||
}
|
||||
// Publish the browser panel first so the pointer can raycast it.
|
||||
if (browser_.isOpen()) {
|
||||
std::vector<std::string> rows;
|
||||
rows.reserve(browser_.entries().size());
|
||||
|
||||
// Publish the browser panel when Files tab is active in the Hub
|
||||
if (browser_.isOpen() && !dlWin && !settings_.isOpen()) {
|
||||
std::vector<VulkanRenderer::BrowserItem> items;
|
||||
items.reserve(browser_.entries().size());
|
||||
for (const auto& e : browser_.entries()) {
|
||||
std::string name = e.label;
|
||||
if (name.size() > 44) name = name.substr(0, 43) + "~";
|
||||
std::string row = name;
|
||||
if (!e.detail.empty()) {
|
||||
while (row.size() < 50) row += ' ';
|
||||
row += e.detail;
|
||||
VulkanRenderer::BrowserItem item;
|
||||
item.name = e.label;
|
||||
item.detail = e.detail;
|
||||
item.isDir = e.isDir;
|
||||
item.isVideo = e.isVideo;
|
||||
item.isBack = e.isBack;
|
||||
item.path = e.path;
|
||||
item.previewPath = e.previewPath;
|
||||
|
||||
const std::string thumbTarget = !e.previewPath.empty()
|
||||
? e.previewPath
|
||||
: (e.isVideo ? e.path : "");
|
||||
|
||||
ThumbnailCache::MediaMeta meta{};
|
||||
bool hasMeta = false;
|
||||
if (!thumbTarget.empty() && thumbTarget.rfind("smb://", 0) != 0) {
|
||||
thumbnailCache_.request(thumbTarget);
|
||||
hasMeta = thumbnailCache_.getMeta(thumbTarget, meta);
|
||||
}
|
||||
rows.push_back(std::move(row));
|
||||
|
||||
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 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 (isTb) return "3D TB";
|
||||
if (isSbs) return "3D SBS";
|
||||
|
||||
if (w >= 7000 || h >= 3500 || contains(lower, "8k") || contains(lower, "4320p")) {
|
||||
return "FLAT 8K";
|
||||
}
|
||||
if (w >= 3600 || h >= 2000 || contains(lower, "4k") ||
|
||||
contains(lower, "2160p") || contains(lower, "uhd")) {
|
||||
return "FLAT 4K";
|
||||
}
|
||||
if (w >= 2500 || h >= 1400 || contains(lower, "1440p") || contains(lower, "2k")) {
|
||||
return "FLAT 1440P";
|
||||
}
|
||||
if (w >= 1800 || h >= 1000 || contains(lower, "1080p") || contains(lower, "fhd")) {
|
||||
return "FLAT 1080P";
|
||||
}
|
||||
if (w >= 1200 || h >= 700 || contains(lower, "720p")) {
|
||||
return "FLAT 720P";
|
||||
}
|
||||
return "FLAT 2D";
|
||||
};
|
||||
|
||||
if (e.isBack) {
|
||||
item.tag = "UP";
|
||||
} else if (e.isNetwork) {
|
||||
item.tag = "SMB";
|
||||
} else if (e.isDir) {
|
||||
// Folders always show FOLDER in the badge; format of contained
|
||||
// video is added to the detail line below.
|
||||
item.tag = "FOLDER";
|
||||
if (!e.previewPath.empty()) {
|
||||
const std::string innerFmt = classifyMediaBadge(
|
||||
e.previewPath, hasMeta ? meta.width : 0, hasMeta ? meta.height : 0);
|
||||
if (!item.detail.empty()) {
|
||||
item.detail = innerFmt + " \xB7 " + item.detail;
|
||||
} else {
|
||||
item.detail = innerFmt;
|
||||
}
|
||||
}
|
||||
} else if (e.isVideo) {
|
||||
item.tag = classifyMediaBadge(
|
||||
e.path.empty() ? e.label : e.path,
|
||||
hasMeta ? meta.width : 0, hasMeta ? meta.height : 0);
|
||||
} else {
|
||||
item.tag = "FILE";
|
||||
}
|
||||
|
||||
if (hasMeta) {
|
||||
if (meta.durationSec > 0.5) {
|
||||
const int tot = static_cast<int>(meta.durationSec + 0.5);
|
||||
const int h = tot / 3600, m = (tot % 3600) / 60, s = tot % 60;
|
||||
char dbuf[24];
|
||||
if (h > 0) std::snprintf(dbuf, sizeof(dbuf), "%d:%02d:%02d", h, m, s);
|
||||
else std::snprintf(dbuf, sizeof(dbuf), "%d:%02d", m, s);
|
||||
item.durationTag = dbuf;
|
||||
}
|
||||
if (meta.width > 0 && meta.height > 0) {
|
||||
char specBuf[96];
|
||||
if (meta.fps > 1.0) {
|
||||
std::snprintf(specBuf, sizeof(specBuf), "%dx%d \xB7 %.0f FPS",
|
||||
meta.width, meta.height, meta.fps);
|
||||
} else {
|
||||
std::snprintf(specBuf, sizeof(specBuf), "%dx%d",
|
||||
meta.width, meta.height);
|
||||
}
|
||||
if (!item.detail.empty()) {
|
||||
item.detail = std::string(specBuf) + " \xB7 " + item.detail;
|
||||
} else {
|
||||
item.detail = specBuf;
|
||||
}
|
||||
}
|
||||
}
|
||||
items.push_back(std::move(item));
|
||||
}
|
||||
|
||||
std::string status = browser_.status();
|
||||
if (browser_.onNetwork() && !browser_.isBusy()) {
|
||||
// Keep any message (e.g. a download hint) visible; just add
|
||||
// the action hint rather than replacing it.
|
||||
status = status.empty()
|
||||
? std::string("Point + Trigger: Stream A: Download")
|
||||
: status + " [A: Download]";
|
||||
if (status.empty()) {
|
||||
status = "A/Trigger: Stream \xB7 X: Download to Local";
|
||||
}
|
||||
}
|
||||
renderer_.setBrowserItems(true, browser_.title(), items,
|
||||
browser_.selected(), status);
|
||||
|
||||
// Feed ready thumbnails to the visible slots (6 cards)
|
||||
const int curFirst = renderer_.browserFirst();
|
||||
const int totalItems = static_cast<int>(items.size());
|
||||
for (int slot = 0; slot < 6; ++slot) {
|
||||
const int idx = curFirst + slot;
|
||||
if (idx >= 0 && idx < totalItems) {
|
||||
const std::string thumbTarget = !items[idx].previewPath.empty()
|
||||
? items[idx].previewPath
|
||||
: (items[idx].isVideo ? items[idx].path : "");
|
||||
if (!thumbTarget.empty()) {
|
||||
std::vector<uint8_t> rgba;
|
||||
if (thumbnailCache_.get(thumbTarget, rgba)) {
|
||||
renderer_.updateCardThumbnail(slot, thumbTarget, rgba.data(), 256, 144);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
renderer_.setBrowser(true, browser_.title(), rows,
|
||||
browser_.selected(), status);
|
||||
browserOpen = true;
|
||||
} else {
|
||||
renderer_.setBrowser(false, "", {}, 0, "");
|
||||
renderer_.setBrowserItems(false, "", {}, 0, "");
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -711,6 +887,7 @@ int App::run() {
|
||||
: (r.label + " : " + r.value));
|
||||
sEnabled.push_back(r.enabled ? 1 : 0);
|
||||
}
|
||||
renderer_.setSettingsSection(settings_.section());
|
||||
renderer_.setSettings(true, settings_.title(), sRows,
|
||||
settings_.selected(), settings_.status(),
|
||||
sEnabled);
|
||||
@@ -755,10 +932,9 @@ int App::run() {
|
||||
}
|
||||
const bool uiPanelActive = browserOpen || settingsOpen ||
|
||||
downloadsWindow_ || !pendingDl_.empty();
|
||||
// The control bar sits below the panels, so keep it available
|
||||
// while the file browser is open (lets you jump straight to the
|
||||
// Downloads window); only hide it for the modal states.
|
||||
const bool barBlocked = settingsOpen || downloadsWindow_ ||
|
||||
// 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();
|
||||
// Controls (buttons + timeline) show only while a controller is
|
||||
// actively moving; they fade after a few seconds of stillness.
|
||||
@@ -813,57 +989,149 @@ int App::run() {
|
||||
}
|
||||
|
||||
#ifdef PIXELVR_HAVE_FREETYPE
|
||||
// Browsing stays interactive during downloads: they run in a separate
|
||||
// process now, so they no longer block directory access. (Only guard
|
||||
// against an in-flight SMB directory scan.)
|
||||
if (browser_.isOpen() && !browser_.isScanning()) {
|
||||
if (const int d = navStep(in.thumbstickY, nowT)) {
|
||||
browser_.move(d);
|
||||
renderer_.scrollBrowser(d);
|
||||
}
|
||||
|
||||
const int hovered = renderer_.browserHovered();
|
||||
if (hovered >= 0) {
|
||||
renderer_.setBrowserSelected(hovered);
|
||||
// The pointer drives the selection, so A acts on the entry
|
||||
// actually being pointed at (not just the last trigger press).
|
||||
browser_.setSelected(hovered);
|
||||
}
|
||||
// Trigger activates the selection whether it was made with the
|
||||
// laser (hovered row) or moved with the thumbstick. Skipped when
|
||||
// the pointer is on the control bar (that click is handled below).
|
||||
if (in.select && renderer_.pointerButton() < 0) {
|
||||
std::string playPath;
|
||||
if (browser_.activate(playPath) == Browser::Result::Play &&
|
||||
!playPath.empty()) {
|
||||
#ifdef PIXELVR_HAVE_MEDIA
|
||||
loadMedia(playPath);
|
||||
#endif
|
||||
browser_.setOpen(false);
|
||||
// Unified Spatial Hub left navigation rail clicks (shared across Files, Downloads, Settings)
|
||||
const bool hubActive = (browserOpen || settingsOpen || downloadsWindow_) && pendingDl_.empty();
|
||||
const int sbHover = renderer_.browserSidebarHovered();
|
||||
if (hubActive && (in.togglePlay || in.select) && sbHover >= 0) {
|
||||
if (sbHover == 0) {
|
||||
// Main Tab: Files
|
||||
downloadsWindow_ = false;
|
||||
settings_.close();
|
||||
if (!browser_.isOpen()) browser_.open();
|
||||
} else if (sbHover == 1) {
|
||||
// Main Tab: Downloads
|
||||
settings_.close();
|
||||
browser_.setOpen(true);
|
||||
downloadsWindow_ = true;
|
||||
} else if (sbHover == 2) {
|
||||
// Main Tab: Settings
|
||||
downloadsWindow_ = false;
|
||||
settings_.open();
|
||||
} else if (sbHover >= 3 && sbHover <= 6) {
|
||||
const int sub = sbHover - 3;
|
||||
if (settings_.isOpen()) {
|
||||
settings_.setSection(sub);
|
||||
} else if (downloadsWindow_) {
|
||||
if (sub <= 2) renderer_.setDownloadsFilter(sub);
|
||||
} else if (browser_.isOpen()) {
|
||||
if (sub == 0) browser_.openHome();
|
||||
else if (sub == 1) browser_.openVideos();
|
||||
else if (sub == 2) browser_.openNetwork();
|
||||
else if (sub == 3) browser_.openDownloads();
|
||||
}
|
||||
}
|
||||
// A: download the focused entry; folders prompt for confirmation.
|
||||
if (in.togglePlay && pendingDl_.empty()) {
|
||||
const Browser::Entry* fe = browser_.focused();
|
||||
if (fe != nullptr && fe->isDir && !fe->isBack &&
|
||||
browser_.onNetwork()) {
|
||||
pendingDl_ = fe->path;
|
||||
pendingDlName_ = fe->label;
|
||||
pendingDlFresh_ = true;
|
||||
browser_.measureFolder(fe->path); // size for the dialog
|
||||
} else {
|
||||
browser_.downloadFocused();
|
||||
interacted = true;
|
||||
}
|
||||
|
||||
// Downloads view interaction inside Unified Spatial Hub
|
||||
if (dlWin && (in.select || in.togglePlay) && sbHover < 0) {
|
||||
const int bi = renderer_.downloadsButtonHovered();
|
||||
if (bi >= 0) {
|
||||
if (browser_.downloadActive()) {
|
||||
if (bi == 0) browser_.togglePause();
|
||||
else if (bi == 1) browser_.stopDownload();
|
||||
else if (bi == 2) browser_.clearFinished();
|
||||
} else if (bi == 0) {
|
||||
browser_.clearFinished();
|
||||
}
|
||||
interacted = true;
|
||||
} 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()) {
|
||||
const auto& j = jobs[static_cast<std::size_t>(hov)];
|
||||
if (j.isDir) {
|
||||
browser_.openPath(j.localPath);
|
||||
downloadsWindow_ = false;
|
||||
} else {
|
||||
#ifdef PIXELVR_HAVE_MEDIA
|
||||
loadMedia(j.localPath);
|
||||
downloadsWindow_ = false;
|
||||
browser_.setOpen(false);
|
||||
#endif
|
||||
}
|
||||
interacted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (browser_.isOpen() && in.recenter && in.headValid) {
|
||||
|
||||
// Files view interaction inside Unified Spatial Hub
|
||||
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);
|
||||
if (dy != 0 || dx != 0) {
|
||||
const int delta = dy * 3 + dx;
|
||||
browser_.move(delta);
|
||||
renderer_.scrollBrowser(0);
|
||||
}
|
||||
|
||||
// Check header pagination buttons [<] and [>]
|
||||
const int pbHover = renderer_.browserPageBtnHovered();
|
||||
if ((in.togglePlay || in.select) && pbHover >= 0 && sbHover < 0) {
|
||||
renderer_.scrollBrowser(pbHover == 0 ? -2 : 2);
|
||||
browser_.setSelected(renderer_.browserFirst());
|
||||
} else if (sbHover < 0) {
|
||||
const bool stickActive = (std::fabs(in.thumbstickY) > 0.25f || std::fabs(in.thumbstickX) > 0.25f);
|
||||
const int hovered = renderer_.browserHovered();
|
||||
if (!stickActive && hovered >= 0) {
|
||||
renderer_.setBrowserSelected(hovered);
|
||||
browser_.setSelected(hovered);
|
||||
}
|
||||
// Open the highlighted entry.
|
||||
if ((in.togglePlay || in.select) &&
|
||||
renderer_.pointerButton() < 0 && hovered >= 0) {
|
||||
std::string playPath;
|
||||
if (browser_.activate(playPath) == Browser::Result::Play &&
|
||||
!playPath.empty()) {
|
||||
#ifdef PIXELVR_HAVE_MEDIA
|
||||
loadMedia(playPath);
|
||||
#endif
|
||||
browser_.setOpen(false);
|
||||
}
|
||||
} else if ((in.togglePlay || in.select) &&
|
||||
renderer_.pointerButton() < 0 && stickActive) {
|
||||
std::string playPath;
|
||||
if (browser_.activate(playPath) == Browser::Result::Play &&
|
||||
!playPath.empty()) {
|
||||
#ifdef PIXELVR_HAVE_MEDIA
|
||||
loadMedia(playPath);
|
||||
#endif
|
||||
browser_.setOpen(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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()) {
|
||||
const Browser::Entry* fe = browser_.focused();
|
||||
if (fe != nullptr && fe->isDir && !fe->isBack &&
|
||||
browser_.onNetwork()) {
|
||||
pendingDl_ = fe->path;
|
||||
pendingDlName_ = fe->label;
|
||||
pendingDlFresh_ = true;
|
||||
browser_.measureFolder(fe->path);
|
||||
} else {
|
||||
browser_.downloadFocused();
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
if (settings_.isOpen()) {
|
||||
if (settings_.isOpen() && sbHover < 0) {
|
||||
if (const int d = navStep(in.thumbstickY, nowT)) {
|
||||
settings_.move(d);
|
||||
settings_.move(d * 2);
|
||||
}
|
||||
if (const int dx = navStepX(in.thumbstickX, nowT)) {
|
||||
settings_.move(dx);
|
||||
}
|
||||
|
||||
const int hovered = renderer_.settingsHovered();
|
||||
@@ -872,7 +1140,7 @@ int App::run() {
|
||||
settings_.setSelected(hovered);
|
||||
}
|
||||
// Trigger activates the selection made by the laser or the stick.
|
||||
if (in.select) {
|
||||
if (in.select && (hovered >= 0 || std::fabs(in.thumbstickY) > 0.25f)) {
|
||||
auto act = settings_.activate();
|
||||
if (act == SettingsMenu::Action::RecenterScreen) {
|
||||
if (in.headValid) {
|
||||
@@ -892,6 +1160,8 @@ int App::run() {
|
||||
VulkanRenderer::StereoMode::TopBottom);
|
||||
} else if (act == SettingsMenu::Action::CycleDistance) {
|
||||
renderer_.adjustScreenDistance(settings_.screenDistance() - 3.0f);
|
||||
} else if (act == SettingsMenu::Action::CycleCurvature) {
|
||||
renderer_.setScreenCurvature(settings_.screenCurvature());
|
||||
}
|
||||
#ifdef PIXELVR_HAVE_MEDIA
|
||||
else if (act == SettingsMenu::Action::PickVideoStream ||
|
||||
@@ -907,14 +1177,16 @@ int App::run() {
|
||||
}
|
||||
}
|
||||
|
||||
// Control-bar buttons stay usable while the file browser is open (the
|
||||
// bar sits below the panel); the video/viewpoint controls stay gated.
|
||||
const bool barOk = !settingsOpen && !downloadsWindow_ &&
|
||||
// Cinema Transport Dock is only active when the Unified Spatial Hub is closed.
|
||||
const bool barOk = !browserOpen && !settingsOpen && !downloadsWindow_ &&
|
||||
pendingDl_.empty();
|
||||
if (barOk) {
|
||||
// Point-and-click the media control bar with the trigger.
|
||||
const int btn = renderer_.pointerButton();
|
||||
if (in.select && btn >= 0) {
|
||||
// The trigger clicks the bar; A does too (A is the reliable
|
||||
// press -- the analog trigger does not always report a click).
|
||||
if ((in.select || in.togglePlay) && btn >= 0) {
|
||||
PIXELVR_LOG_INFO("control bar click: button=%d", btn);
|
||||
const auto cb = static_cast<VulkanRenderer::CtrlButton>(btn);
|
||||
if (cb == VulkanRenderer::CtrlButton::Stop) {
|
||||
#ifdef PIXELVR_HAVE_MEDIA
|
||||
@@ -927,14 +1199,31 @@ int App::run() {
|
||||
#endif
|
||||
} else if (cb == VulkanRenderer::CtrlButton::Settings) {
|
||||
settings_.open();
|
||||
} else if (cb == VulkanRenderer::CtrlButton::Downloads) {
|
||||
downloadsWindow_ = !downloadsWindow_;
|
||||
}
|
||||
} else if (cb == VulkanRenderer::CtrlButton::Recenter) {
|
||||
if (in.headValid) {
|
||||
renderer_.recenterScreen(in.headPos, in.headForward);
|
||||
}
|
||||
} else if (cb == VulkanRenderer::CtrlButton::Downloads ||
|
||||
cb == VulkanRenderer::CtrlButton::Files) {
|
||||
#ifdef PIXELVR_HAVE_FREETYPE
|
||||
else if (cb == VulkanRenderer::CtrlButton::Files) {
|
||||
browser_.open();
|
||||
}
|
||||
#endif
|
||||
} else if (cb == VulkanRenderer::CtrlButton::Download) {
|
||||
// Download the entry highlighted in the file browser;
|
||||
// folders ask for confirmation (with the size) first.
|
||||
if (browser_.isOpen() && pendingDl_.empty()) {
|
||||
const Browser::Entry* fe = browser_.focused();
|
||||
if (fe != nullptr && fe->isDir && !fe->isBack &&
|
||||
browser_.onNetwork()) {
|
||||
pendingDl_ = fe->path;
|
||||
pendingDlName_ = fe->label;
|
||||
pendingDlFresh_ = true;
|
||||
browser_.measureFolder(fe->path);
|
||||
} else {
|
||||
browser_.downloadFocused();
|
||||
}
|
||||
}
|
||||
}
|
||||
#ifdef PIXELVR_HAVE_MEDIA
|
||||
else if (mediaOpen_ &&
|
||||
cb == VulkanRenderer::CtrlButton::PlayPause) {
|
||||
@@ -945,6 +1234,43 @@ int App::run() {
|
||||
} else if (mediaOpen_ &&
|
||||
cb == VulkanRenderer::CtrlButton::SeekFwd) {
|
||||
media_.seekRelative(10.0);
|
||||
} else if (cb == VulkanRenderer::CtrlButton::FormatToggle) {
|
||||
int pm = settings_.projectionMode();
|
||||
int sm = settings_.stereoMode();
|
||||
if (pm == 0 && sm == 0) {
|
||||
settings_.setStereoMode(1);
|
||||
renderer_.setStereoMode(VulkanRenderer::StereoMode::SideBySide);
|
||||
} else if (pm == 0) {
|
||||
settings_.setProjectionMode(2);
|
||||
settings_.setStereoMode(1);
|
||||
renderer_.setProjectionMode(VulkanRenderer::ProjectionMode::Equirect180);
|
||||
renderer_.setStereoMode(VulkanRenderer::StereoMode::SideBySide);
|
||||
} else if (pm == 2 && sm == 1) {
|
||||
settings_.setStereoMode(2);
|
||||
renderer_.setStereoMode(VulkanRenderer::StereoMode::TopBottom);
|
||||
} else if (pm == 2) {
|
||||
settings_.setProjectionMode(1);
|
||||
settings_.setStereoMode(1);
|
||||
renderer_.setProjectionMode(VulkanRenderer::ProjectionMode::Equirect360);
|
||||
renderer_.setStereoMode(VulkanRenderer::StereoMode::SideBySide);
|
||||
} else {
|
||||
settings_.setProjectionMode(0);
|
||||
settings_.setStereoMode(0);
|
||||
renderer_.setProjectionMode(VulkanRenderer::ProjectionMode::Flat);
|
||||
renderer_.setStereoMode(VulkanRenderer::StereoMode::Mono);
|
||||
}
|
||||
} else if (cb == VulkanRenderer::CtrlButton::AudioToggle) {
|
||||
if (mediaOpen_ && media_.audioStreams().size() > 1) {
|
||||
switchTracks(false);
|
||||
}
|
||||
} else if (cb == VulkanRenderer::CtrlButton::ScreenToggle) {
|
||||
if (settings_.projectionMode() == 0) {
|
||||
settings_.setCurvatureIdx(settings_.curvatureIdx() + 1);
|
||||
renderer_.setScreenCurvature(settings_.screenCurvature());
|
||||
} else {
|
||||
renderer_.setSpherePitch(0.0f);
|
||||
renderer_.setSphereZoom(1.0f);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
interacted = true;
|
||||
@@ -969,6 +1295,12 @@ int App::run() {
|
||||
renderer_.adjustScreenDistance(in.thumbstickY * 0.04f);
|
||||
interacted = true;
|
||||
}
|
||||
// When pointing at the Screen Curve pill on the HUD, thumbstick X smoothly adjusts curvature!
|
||||
if (renderer_.pointerButton() == static_cast<int>(VulkanRenderer::CtrlButton::ScreenToggle) &&
|
||||
std::fabs(in.thumbstickX) > 0.15f && dt > 0.0f) {
|
||||
renderer_.adjustScreenCurvature(in.thumbstickX * dt * 0.65f);
|
||||
interacted = true;
|
||||
}
|
||||
} else if (dt > 0.0f) {
|
||||
if (std::fabs(in.thumbstickRY) > 0.15f) {
|
||||
renderer_.adjustSpherePitch(in.thumbstickRY * dt * 0.6f);
|
||||
@@ -1044,6 +1376,53 @@ int App::run() {
|
||||
elapsed = scrubbing_ ? scrubTarget_ : media_.position();
|
||||
progress = static_cast<float>(elapsed / total);
|
||||
}
|
||||
std::string fLabel = "2D FLAT";
|
||||
if (settings_.projectionMode() == 0) {
|
||||
fLabel = (settings_.stereoMode() == 0) ? "2D FLAT" :
|
||||
(settings_.stereoMode() == 1) ? "3D SBS" : "3D TB";
|
||||
} else if (settings_.projectionMode() == 2) {
|
||||
fLabel = (settings_.stereoMode() == 1) ? "VR 180 SBS" :
|
||||
(settings_.stereoMode() == 2) ? "VR 180 TB" : "180 MONO";
|
||||
} else {
|
||||
fLabel = (settings_.stereoMode() == 1) ? "360 SBS" : "360 MONO";
|
||||
}
|
||||
|
||||
std::string aLabel = "AUDIO";
|
||||
bool aEnabled = false;
|
||||
if (mediaOpen_) {
|
||||
const auto aStreams = media_.audioStreams();
|
||||
const int curA = media_.currentAudioStream();
|
||||
for (const auto& as : aStreams) {
|
||||
if (as.index == curA) {
|
||||
std::string l = as.language;
|
||||
for (char& c : l) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
||||
if (l.empty() || l == "und" || l == "undef" || l == "undefined") {
|
||||
aLabel = (as.label.empty() || as.label == "und" || as.label == "undef") ? "DEFAULT" : as.label;
|
||||
} else {
|
||||
aLabel = as.language;
|
||||
}
|
||||
for (char& c : aLabel) c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
|
||||
if (aLabel.size() > 8) aLabel = aLabel.substr(0, 8);
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Only enable audio cycling if there is more than 1 audio stream
|
||||
aEnabled = (aStreams.size() > 1);
|
||||
}
|
||||
|
||||
std::string sLabel = "RESET HORIZON";
|
||||
if (settings_.projectionMode() == 0) {
|
||||
const int cPct = static_cast<int>(renderer_.screenCurvature() * 100.0f + 0.5f);
|
||||
if (cPct <= 2) {
|
||||
sLabel = "SCREEN: FLAT";
|
||||
} else {
|
||||
char cbuf[32];
|
||||
std::snprintf(cbuf, sizeof(cbuf), "CURVED: %d%%", cPct);
|
||||
sLabel = cbuf;
|
||||
}
|
||||
}
|
||||
|
||||
renderer_.setHudLabels(fLabel, aLabel, sLabel, true, aEnabled);
|
||||
renderer_.setTransport(controlsUp, progress, paused, elapsed, total);
|
||||
#else
|
||||
(void)interacted;
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
#ifdef PIXELVR_HAVE_FREETYPE
|
||||
#include "ui/browser.hpp"
|
||||
#include "ui/thumbnail_cache.hpp"
|
||||
#endif
|
||||
|
||||
#include "ui/settings_menu.hpp"
|
||||
@@ -50,6 +51,8 @@ private:
|
||||
bool seekStepArmed_ = true; // right-stick +/-30s debounce
|
||||
bool navArmed_ = true; // menu nav: armed for the next stick press
|
||||
std::chrono::steady_clock::time_point navNextRepeat_{}; // nav auto-repeat
|
||||
bool navArmedX_ = true; // horizontal grid nav
|
||||
std::chrono::steady_clock::time_point navNextRepeatX_{};
|
||||
bool scrubbing_ = false; // dragging the timeline / stick-scrubbing
|
||||
double scrubTarget_ = 0.0; // pending scrub position (seconds)
|
||||
std::chrono::steady_clock::time_point perfLastLog_{};
|
||||
@@ -92,9 +95,11 @@ private:
|
||||
void updateStats();
|
||||
// Menu nav stick auto-repeat: -1/+1 to step (initial press + hold), else 0.
|
||||
int navStep(float axisY, std::chrono::steady_clock::time_point now);
|
||||
int navStepX(float axisX, std::chrono::steady_clock::time_point now);
|
||||
|
||||
#ifdef PIXELVR_HAVE_FREETYPE
|
||||
Browser browser_;
|
||||
ThumbnailCache thumbnailCache_;
|
||||
#endif
|
||||
|
||||
SettingsMenu settings_;
|
||||
|
||||
@@ -26,7 +26,7 @@ FontAtlas buildFontAtlas(const char* ttfPath, int pixelHeight) {
|
||||
FT_Set_Pixel_Sizes(face, 0, static_cast<FT_UInt>(pixelHeight));
|
||||
|
||||
constexpr int kFirst = 32;
|
||||
constexpr int kLast = 126;
|
||||
constexpr int kLast = 127;
|
||||
constexpr int kCols = 16;
|
||||
const int count = kLast - kFirst + 1;
|
||||
const int rows = (count + kCols - 1) / kCols;
|
||||
@@ -39,8 +39,13 @@ FontAtlas buildFontAtlas(const char* ttfPath, int pixelHeight) {
|
||||
atlas.ascent = face->size->metrics.ascender / 64.0f;
|
||||
|
||||
for (int ch = kFirst; ch <= kLast; ++ch) {
|
||||
if (FT_Load_Char(face, static_cast<FT_ULong>(ch), FT_LOAD_DEFAULT) != 0) {
|
||||
continue;
|
||||
FT_ULong codepoint = (ch == 127) ? 0x2022UL : static_cast<FT_ULong>(ch);
|
||||
if (FT_Load_Char(face, codepoint, FT_LOAD_DEFAULT) != 0) {
|
||||
if (ch == 127 && FT_Load_Char(face, 0x00B7UL, FT_LOAD_DEFAULT) != 0) {
|
||||
continue;
|
||||
} else if (ch != 127) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const FT_GlyphSlot g = face->glyph;
|
||||
// Render a signed-distance field (crisp at any VR distance); fall back to
|
||||
|
||||
+1355
-535
File diff suppressed because it is too large.
Load diff
+115
-8
@@ -96,6 +96,13 @@ public:
|
||||
void recenterScreen(const float headPos[3], const float headForward[3]);
|
||||
// Moves the flat screen farther (positive) or nearer (negative), clamped.
|
||||
void adjustScreenDistance(float delta);
|
||||
// Parametric curvature for cinema screen: 0.0 = flat plane, 1.0 = full cylinder wrap.
|
||||
void setScreenCurvature(float c) { screenCurvature_ = c < 0.0f ? 0.0f : (c > 1.0f ? 1.0f : c); }
|
||||
float screenCurvature() const { return screenCurvature_; }
|
||||
void adjustScreenCurvature(float delta) {
|
||||
setScreenCurvature(screenCurvature_ + delta);
|
||||
}
|
||||
|
||||
|
||||
// Transport HUD: progress in [0,1]. Drawn head-locked when visible.
|
||||
void setTransport(bool visible, float progress, bool paused,
|
||||
@@ -110,22 +117,61 @@ public:
|
||||
bool pointerOnTimeToggle() const { return pointerOnTimeToggle_; }
|
||||
void toggleTimeDisplay() { timeShowRemaining_ = !timeShowRemaining_; }
|
||||
|
||||
// File browser overlay: a title, list rows, selected index and a status line.
|
||||
struct BrowserItem {
|
||||
std::string name;
|
||||
std::string path;
|
||||
std::string detail;
|
||||
std::string tag;
|
||||
std::string durationTag;
|
||||
std::string previewPath;
|
||||
bool isDir = false;
|
||||
bool isVideo = false;
|
||||
bool isBack = false;
|
||||
};
|
||||
|
||||
struct DownloadItem {
|
||||
std::string label;
|
||||
int state = 0; // 0=active, 1=done, 2=failed, 3=stopped
|
||||
float progress = 0.0f; // 0.0 to 1.0
|
||||
std::string detail;
|
||||
};
|
||||
|
||||
// File browser overlay: rich spatial card grid + sidebar navigation
|
||||
void setBrowserItems(bool visible, const std::string& title,
|
||||
const std::vector<BrowserItem>& items, int selected,
|
||||
const std::string& status);
|
||||
void setBrowser(bool visible, const std::string& title,
|
||||
const std::vector<std::string>& rows, int selected,
|
||||
const std::string& status);
|
||||
void updateCardThumbnail(int slot, const std::string& path, const uint8_t* rgba, int width, int height);
|
||||
void clearCardThumbnails();
|
||||
int browserFirst() const { return browserFirst_; }
|
||||
int browserSidebarHovered() const { return browserSidebarHovered_; }
|
||||
int browserPageBtnHovered() const { return browserPageBtnHovered_; }
|
||||
void setDownloadsFilter(int f) { downloadsFilter_ = f; }
|
||||
int downloadsFilter() const { return downloadsFilter_; }
|
||||
void setActiveDownloadCount(int c) { activeDownloadCount_ = c; }
|
||||
void setSettingsSection(int s) { settingsSection_ = s; }
|
||||
void setUiAppearance(float textScale, int textColorIdx, int accentColorIdx,
|
||||
float uiDistance) {
|
||||
uiTextScale_ = textScale;
|
||||
uiTextColorIdx_ = textColorIdx;
|
||||
uiAccentColorIdx_ = accentColorIdx;
|
||||
uiDistance_ = uiDistance;
|
||||
}
|
||||
|
||||
// Downloads overlay: a simple list of download jobs plus a status line,
|
||||
// shown while downloads run (and briefly after) with the browser closed.
|
||||
// Downloads overlay: modern glassmorphic window with job cards
|
||||
void setDownloads(bool visible, const std::string& title,
|
||||
const std::vector<std::string>& rows,
|
||||
const std::string& status,
|
||||
const std::vector<std::string>& buttons = {}) {
|
||||
const std::vector<std::string>& buttons = {},
|
||||
const std::vector<DownloadItem>& jobs = {}) {
|
||||
downloadsVisible_ = visible;
|
||||
downloadsTitle_ = title;
|
||||
downloadsRows_ = rows;
|
||||
downloadsStatus_ = status;
|
||||
downloadsButtons_ = buttons; // content-sized buttons along the bottom
|
||||
downloadJobs_ = jobs;
|
||||
}
|
||||
// Draws a stronger modal backdrop behind the panel (for confirmations).
|
||||
void setDownloadsModal(bool modal) { downloadsModal_ = modal; }
|
||||
@@ -176,19 +222,23 @@ public:
|
||||
Checkmark = 10,
|
||||
Stop = 11,
|
||||
Download = 12,
|
||||
List = 13,
|
||||
GlowRing = 14,
|
||||
Speaker = 15,
|
||||
Headset = 16,
|
||||
CurvedDisplay = 17,
|
||||
Chevron = 18,
|
||||
Home = 19,
|
||||
};
|
||||
|
||||
// The browser row currently under the pointer, or -1 if none.
|
||||
int pointerRow() const { return pointerRow_; }
|
||||
int browserHovered() const { return browserHovered_; }
|
||||
int browserFirst() const { return browserFirst_; }
|
||||
void scrollBrowser(int deltaRows);
|
||||
|
||||
// Updates just the highlighted browser row (used by the pointer each frame).
|
||||
void setBrowserSelected(int index) { browserSelected_ = index; }
|
||||
|
||||
// Media control bar shown below the video (point-and-click transport). The
|
||||
// buttons are: seek back, play/pause, seek forward, open files, settings, recenter.
|
||||
enum class CtrlButton {
|
||||
SeekBack = 0,
|
||||
PlayPause = 1,
|
||||
@@ -196,9 +246,33 @@ public:
|
||||
Stop = 3,
|
||||
Files = 4,
|
||||
Settings = 5,
|
||||
Downloads = 6
|
||||
Downloads = 6, // open the downloads window (list of jobs)
|
||||
Download = 7, // download the focused file-browser entry
|
||||
FormatToggle = 8,
|
||||
AudioToggle = 9,
|
||||
ScreenToggle = 10,
|
||||
Recenter = 11 // recenter screen/view from HUD
|
||||
};
|
||||
void setControlBar(bool visible) { controlBarVisible_ = visible; }
|
||||
void setHudLabels(const std::string& format, const std::string& audio,
|
||||
const std::string& screen, bool formatEnabled = true,
|
||||
bool audioEnabled = true) {
|
||||
hudFormat_ = format;
|
||||
hudAudio_ = audio;
|
||||
hudScreen_ = screen;
|
||||
hudFormatEnabled_ = formatEnabled;
|
||||
hudAudioEnabled_ = audioEnabled;
|
||||
}
|
||||
|
||||
// Per-hand laser pointer toggle (grip 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.
|
||||
void setLaserOn(int hand, bool on) {
|
||||
if (hand >= 0 && hand < 2) laserOn_[hand] = on;
|
||||
}
|
||||
bool laserOn(int hand) const {
|
||||
return (hand >= 0 && hand < 2) ? laserOn_[hand] : false;
|
||||
}
|
||||
|
||||
// The control-bar button currently under the pointer, or -1 if none.
|
||||
int pointerButton() const { return pointerButton_; }
|
||||
@@ -305,6 +379,7 @@ private:
|
||||
bool downloadsFailed_ = false;
|
||||
bool downloadsModal_ = false;
|
||||
std::vector<std::string> downloadsButtons_;
|
||||
std::vector<DownloadItem> downloadJobs_;
|
||||
int downloadsBtnHovered_ = -1;
|
||||
int downloadsHovered_ = -1;
|
||||
|
||||
@@ -318,6 +393,7 @@ private:
|
||||
float spherePitch_ = 0.0f; // 360/180 viewpoint tilt (radians)
|
||||
float sphereZoom_ = 1.0f; // 360/180 FOV scale (>1 pushes content farther)
|
||||
float screenDistance_ = 3.0f;
|
||||
float screenCurvature_ = 0.0f; // 0.0 = flat plane (default), 1.0 = deep cinema curve
|
||||
|
||||
// Transport HUD state.
|
||||
bool transportVisible_ = false;
|
||||
@@ -327,6 +403,25 @@ private:
|
||||
double transportDuration_ = 0.0; // seconds total
|
||||
bool timeShowRemaining_ = false; // right readout shows time-left vs. total
|
||||
bool pointerOnTimeToggle_ = false;
|
||||
std::string hudFormat_ = "2D FLAT";
|
||||
std::string hudAudio_ = "AUDIO";
|
||||
std::string hudScreen_ = "CURVED";
|
||||
bool hudFormatEnabled_ = true;
|
||||
bool hudAudioEnabled_ = true;
|
||||
float sphereFollowYaw_ = 0.0f;
|
||||
bool sphereFollowInit_ = false;
|
||||
|
||||
struct CardThumbnail {
|
||||
VkImage image = VK_NULL_HANDLE;
|
||||
VkDeviceMemory memory = VK_NULL_HANDLE;
|
||||
VkImageView view = VK_NULL_HANDLE;
|
||||
VkDescriptorSet set = VK_NULL_HANDLE;
|
||||
std::string path;
|
||||
bool ready = false;
|
||||
};
|
||||
CardThumbnail cardThumbs_[6];
|
||||
VkPipeline thumbPipeline_ = VK_NULL_HANDLE;
|
||||
VkDescriptorPool thumbPool_ = VK_NULL_HANDLE;
|
||||
|
||||
// Text rendering (FreeType glyph atlas) + file browser overlay.
|
||||
FontAtlas fontAtlas_;
|
||||
@@ -347,6 +442,16 @@ private:
|
||||
int browserSelected_ = 0;
|
||||
int browserHovered_ = -1; // pointer hover target (independent of scroll)
|
||||
int browserFirst_ = 0; // scroll offset for visible window
|
||||
std::vector<BrowserItem> browserItems_;
|
||||
int browserSidebarHovered_ = -1;
|
||||
int browserPageBtnHovered_ = -1;
|
||||
int downloadsFilter_ = 0; // 0=All, 1=Active, 2=Completed
|
||||
int activeDownloadCount_ = 0;
|
||||
int settingsSection_ = 0;
|
||||
float uiTextScale_ = 1.20f;
|
||||
int uiTextColorIdx_ = 0;
|
||||
int uiAccentColorIdx_ = 0;
|
||||
float uiDistance_ = 1.9f;
|
||||
|
||||
// Settings panel overlay
|
||||
bool settingsVisible_ = false;
|
||||
@@ -363,6 +468,8 @@ 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};
|
||||
float pointerHit_[3] = {0.0f, 0.0f, 0.0f};
|
||||
bool controlBarVisible_ = false;
|
||||
int pointerButton_ = -1;
|
||||
|
||||
@@ -464,6 +464,11 @@ struct MediaEngine::Impl {
|
||||
return false;
|
||||
}
|
||||
codec->pkt_timebase = s->time_base;
|
||||
codec->thread_count = 0; // Multi-threaded SW decode across all CPU cores
|
||||
codec->thread_type = FF_THREAD_FRAME | FF_THREAD_SLICE;
|
||||
if (std::strstr(videoDecoder->name, "v4l2m2m") != nullptr) {
|
||||
av_opt_set_int(codec->priv_data, "num_output_buffers", 4, 0);
|
||||
}
|
||||
if (decoderDrm) {
|
||||
av_opt_set_int(codec->priv_data, "num_capture_buffers", captureBuffers, 0);
|
||||
av_opt_set_int(codec->priv_data, "drm_prime", 1, 0);
|
||||
@@ -922,7 +927,12 @@ bool MediaEngine::open(const std::string& path) {
|
||||
return false;
|
||||
}
|
||||
impl_->codec->pkt_timebase = stream->time_base;
|
||||
impl_->codec->thread_count = 0; // Auto-detect and utilize all CPU cores
|
||||
impl_->codec->thread_type = FF_THREAD_FRAME | FF_THREAD_SLICE;
|
||||
impl_->videoDecoder = dec; // retained so the decode thread can reopen on seek
|
||||
if (std::strstr(dec->name, "v4l2m2m") != nullptr) {
|
||||
av_opt_set_int(impl_->codec->priv_data, "num_output_buffers", 4, 0);
|
||||
}
|
||||
impl_->decoderDrm = drmPrime && std::strstr(dec->name, "v4l2m2m") != nullptr;
|
||||
if (impl_->decoderDrm) {
|
||||
// Export decoder output as DMA-BUF (AV_PIX_FMT_DRM_PRIME) for zero-copy
|
||||
|
||||
+101
-6
@@ -81,11 +81,14 @@ std::string smb_parent(const std::string& uri) {
|
||||
void sort_and_append(std::vector<Browser::Entry>& out,
|
||||
std::vector<Browser::Entry>& dirs,
|
||||
std::vector<Browser::Entry>& files) {
|
||||
auto by_label = [](const Browser::Entry& a, const Browser::Entry& b) {
|
||||
auto by_media_then_label = [](const Browser::Entry& a, const Browser::Entry& b) {
|
||||
const bool aHasMedia = a.isVideo || !a.previewPath.empty();
|
||||
const bool bHasMedia = b.isVideo || !b.previewPath.empty();
|
||||
if (aHasMedia != bHasMedia) return aHasMedia > bHasMedia;
|
||||
return a.label < b.label;
|
||||
};
|
||||
std::sort(dirs.begin(), dirs.end(), by_label);
|
||||
std::sort(files.begin(), files.end(), by_label);
|
||||
std::sort(dirs.begin(), dirs.end(), by_media_then_label);
|
||||
std::sort(files.begin(), files.end(), by_media_then_label);
|
||||
out.insert(out.end(), dirs.begin(), dirs.end());
|
||||
out.insert(out.end(), files.begin(), files.end());
|
||||
}
|
||||
@@ -160,12 +163,65 @@ void Browser::scanLocalInto(const std::string& dir, ScanResult& out) {
|
||||
if (name.empty() || name[0] == '.') continue; // skip hidden
|
||||
std::error_code dec;
|
||||
if (it->is_directory(dec)) {
|
||||
dirs.push_back({name + "/", p.string(), true, false, false, false});
|
||||
if (dir == homeDir_) {
|
||||
if (name == "devkit-game" || name == "devkit-utils" ||
|
||||
name == "ffbuild" || name == "pixelvr-src" ||
|
||||
name == "steamvr" || name == "work" ||
|
||||
name == "Desktop" || name == "Templates" ||
|
||||
name == "Public" || name == "Documents" || name == "Music") {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
std::string preview;
|
||||
long long bestPreviewSize = -1;
|
||||
int videoCount = 0;
|
||||
std::error_code sec;
|
||||
for (fs::directory_iterator subIt(p, sec), subEnd; !sec && subIt != subEnd; subIt.increment(sec)) {
|
||||
const std::string subName = subIt->path().filename().string();
|
||||
if (subName.empty() || subName[0] == '.') continue;
|
||||
std::error_code sdec;
|
||||
if (!subIt->is_directory(sdec) && is_video(subName)) {
|
||||
std::error_code fsec;
|
||||
const long long vsz = static_cast<long long>(fs::file_size(subIt->path(), fsec));
|
||||
const long long score = (!fsec ? vsz : 1);
|
||||
if (score > bestPreviewSize) {
|
||||
bestPreviewSize = score;
|
||||
preview = subIt->path().string();
|
||||
}
|
||||
++videoCount;
|
||||
} else if (subIt->is_directory(sdec)) {
|
||||
// Check one level deeper (e.g. ~/Videos/MovieFolder/movie.mp4)
|
||||
std::error_code ssec2;
|
||||
for (fs::directory_iterator subIt2(subIt->path(), ssec2), subEnd2;
|
||||
!ssec2 && subIt2 != subEnd2; subIt2.increment(ssec2)) {
|
||||
const std::string subName2 = subIt2->path().filename().string();
|
||||
if (subName2.empty() || subName2[0] == '.') continue;
|
||||
if (!subIt2->is_directory(ssec2) && is_video(subName2)) {
|
||||
std::error_code fsec2;
|
||||
const long long vsz2 = static_cast<long long>(fs::file_size(subIt2->path(), fsec2));
|
||||
const long long score2 = (!fsec2 ? vsz2 : 1);
|
||||
if (score2 > bestPreviewSize) {
|
||||
bestPreviewSize = score2;
|
||||
preview = subIt2->path().string();
|
||||
}
|
||||
++videoCount;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
std::string detail = (videoCount > 0)
|
||||
? (std::to_string(videoCount) + (videoCount == 1 ? " video" : " videos") +
|
||||
(bestPreviewSize > 0 ? " \xB7 " + format_size(bestPreviewSize) : ""))
|
||||
: "Folder";
|
||||
dirs.push_back({name + "/", p.string(), true, false, false, false, detail,
|
||||
bestPreviewSize > 0 ? bestPreviewSize : 0, preview});
|
||||
} else {
|
||||
const bool isVid = is_video(name);
|
||||
if (dir == homeDir_ && !isVid) continue; // hide log/tarball files in home root
|
||||
std::error_code sec;
|
||||
const long long sz = static_cast<long long>(fs::file_size(p, sec));
|
||||
files.push_back({name, p.string(), false, is_video(name), false, false,
|
||||
format_size(sec ? 0 : sz), sec ? 0 : sz});
|
||||
files.push_back({name, p.string(), false, isVid, false, false,
|
||||
format_size(sec ? 0 : sz), sec ? 0 : sz, isVid ? p.string() : ""});
|
||||
}
|
||||
}
|
||||
sort_and_append(out.entries, dirs, files);
|
||||
@@ -305,6 +361,45 @@ void Browser::openPath(const std::string& localDir) {
|
||||
beginScan(localDir, false);
|
||||
}
|
||||
|
||||
void Browser::openNetwork() {
|
||||
open_ = true;
|
||||
browseOpen_.store(true);
|
||||
status_.clear();
|
||||
beginScan("smb://", true);
|
||||
}
|
||||
|
||||
void Browser::openHome() {
|
||||
openPath(homeDir_);
|
||||
}
|
||||
|
||||
void Browser::openVideos() {
|
||||
const std::string v = homeDir_ + "/Videos";
|
||||
std::error_code ec;
|
||||
if (fs::is_directory(v, ec)) {
|
||||
openPath(v);
|
||||
} else {
|
||||
openPath(homeDir_);
|
||||
}
|
||||
}
|
||||
|
||||
void Browser::openDownloads() {
|
||||
std::error_code ec;
|
||||
if (!fs::is_directory(downloadDir_, ec)) {
|
||||
fs::create_directories(downloadDir_, ec);
|
||||
}
|
||||
openPath(downloadDir_);
|
||||
}
|
||||
|
||||
void Browser::openDrives() {
|
||||
const std::string d = "/run/media";
|
||||
std::error_code ec;
|
||||
if (fs::is_directory(d, ec)) {
|
||||
openPath(d);
|
||||
} else {
|
||||
openPath("/");
|
||||
}
|
||||
}
|
||||
|
||||
void Browser::runScan(std::string location, bool isSmb) {
|
||||
ScanResult r;
|
||||
if (isSmb) {
|
||||
|
||||
@@ -23,6 +23,7 @@ public:
|
||||
bool isNetwork = false; // "Network (SMB)" entry point
|
||||
std::string detail; // human-readable type/size for the row
|
||||
long long size = 0; // bytes (0 if unknown)
|
||||
std::string previewPath; // video file inside folder or self (for thumbnail)
|
||||
};
|
||||
enum class Result { None, Navigated, Play };
|
||||
|
||||
@@ -33,6 +34,12 @@ public:
|
||||
// Opens the browser directly at a local directory (used when a finished
|
||||
// folder download is selected).
|
||||
void openPath(const std::string& localDir);
|
||||
// Direct shortcut navigation (for spatial browser sidebar rail)
|
||||
void openNetwork();
|
||||
void openHome();
|
||||
void openVideos();
|
||||
void openDownloads();
|
||||
void openDrives();
|
||||
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).
|
||||
|
||||
+135
-34
@@ -29,6 +29,30 @@ const char* kLangCodes[] = {"", "eng", "deu", "fra", "spa",
|
||||
"jpn", "ita", "rus", "chi", "kor"};
|
||||
constexpr int kLangCount = 10;
|
||||
|
||||
const char* kCurvatureNames[] = {"Flat (0%)", "Gentle (25%)", "Cinema (50%)",
|
||||
"Deep Curve (75%)", "IMAX Wrap (100%)"};
|
||||
const float kCurvatureValues[] = {0.0f, 0.25f, 0.50f, 0.75f, 1.00f};
|
||||
constexpr int kCurvatureCount = 5;
|
||||
|
||||
const char* kUiTextScaleNames[] = {"Compact (90%)", "Standard (105%)",
|
||||
"Large (120%)", "Extra Large (140%)",
|
||||
"Huge (160%)"};
|
||||
const float kUiTextScaleValues[] = {0.90f, 1.05f, 1.20f, 1.40f, 1.60f};
|
||||
constexpr int kUiTextScaleCount = 5;
|
||||
|
||||
const char* kUiTextColorNames[] = {"Pure White", "Ice Cyan", "Warm Cream",
|
||||
"Soft Gold", "Mint Green"};
|
||||
constexpr int kUiTextColorCount = 5;
|
||||
|
||||
const char* kUiAccentColorNames[] = {"Cyber Cyan", "Emerald Green", "Amber Gold",
|
||||
"Royal Purple", "Crimson Red"};
|
||||
constexpr int kUiAccentColorCount = 5;
|
||||
|
||||
const char* kUiDistanceNames[] = {"Near (1.5 m)", "Comfort (1.9 m)",
|
||||
"Medium (2.3 m)", "Far (2.8 m)"};
|
||||
const float kUiDistanceValues[] = {1.5f, 1.9f, 2.3f, 2.8f};
|
||||
constexpr int kUiDistanceCount = 4;
|
||||
|
||||
std::string getConfigPath() {
|
||||
#ifdef _WIN32
|
||||
const char* appData = std::getenv("APPDATA");
|
||||
@@ -53,6 +77,7 @@ SettingsMenu::SettingsMenu() {
|
||||
|
||||
void SettingsMenu::open() {
|
||||
open_ = true;
|
||||
if (section_ < 0 || section_ > 3) section_ = 0;
|
||||
refresh();
|
||||
}
|
||||
|
||||
@@ -81,7 +106,7 @@ void SettingsMenu::refresh() {
|
||||
}
|
||||
|
||||
if (section_ == 0) {
|
||||
title_ = "Display & Projection";
|
||||
title_ = "Settings > Display & Cinema";
|
||||
const char* projNames[] = {"Flat Screen", "360 Equirectangular",
|
||||
"180 Equirectangular"};
|
||||
const char* stereoNames[] = {"2D Mono", "Side-by-Side (SBS)",
|
||||
@@ -94,28 +119,35 @@ void SettingsMenu::refresh() {
|
||||
Action::CycleProjection});
|
||||
rows_.push_back({"Stereo 3D Format", stereoNames[stereoMode_ % 3],
|
||||
Action::CycleStereo});
|
||||
rows_.push_back({"Swap Left/Right Eye", swapEyes_ ? "On" : "Off",
|
||||
Action::ToggleSwapEyes});
|
||||
rows_.push_back({"Cinema Screen Curve", kCurvatureNames[curvatureIdx_ % kCurvatureCount],
|
||||
Action::CycleCurvature});
|
||||
rows_.push_back({"Screen Distance", distStream.str(),
|
||||
Action::CycleDistance});
|
||||
rows_.push_back({"Swap Left/Right Eye", swapEyes_ ? "On" : "Off",
|
||||
Action::ToggleSwapEyes});
|
||||
rows_.push_back({"Recenter Screen View", "Recenter Now",
|
||||
Action::RecenterScreen});
|
||||
} else if (section_ == 1) {
|
||||
title_ = "Controls & Bindings";
|
||||
auto btnLabel = [&](int func, int btn) -> std::string {
|
||||
return listeningFunc_ == func
|
||||
? std::string("[ press a button ]")
|
||||
: std::string(kButtonNames[btn % kButtonCount]);
|
||||
};
|
||||
rows_.push_back({"Play / Pause", btnLabel(0, bindPlayBtn_),
|
||||
Action::ListenPlay});
|
||||
rows_.push_back({"Open Menu / Browser", btnLabel(1, bindMenuBtn_),
|
||||
Action::ListenMenu});
|
||||
rows_.push_back({"Recenter View", btnLabel(2, bindRecenterBtn_),
|
||||
Action::ListenRecenter});
|
||||
rows_.push_back({"Select / Click", "Trigger", Action::None});
|
||||
rows_.push_back({"Controller Models", drawModels_ ? "On" : "Off",
|
||||
Action::ToggleModels});
|
||||
title_ = "Settings > UI & Appearance";
|
||||
rows_.push_back({"UI Text Size",
|
||||
kUiTextScaleNames[uiTextScaleIdx_ % kUiTextScaleCount],
|
||||
Action::CycleUiTextScale});
|
||||
rows_.push_back({"UI Text Color",
|
||||
kUiTextColorNames[uiTextColorIdx_ % kUiTextColorCount],
|
||||
Action::CycleUiTextColor});
|
||||
rows_.push_back({"UI Accent Theme",
|
||||
kUiAccentColorNames[uiAccentColorIdx_ % kUiAccentColorCount],
|
||||
Action::CycleUiAccentColor});
|
||||
rows_.push_back({"UI Panel Distance",
|
||||
kUiDistanceNames[uiDistanceIdx_ % kUiDistanceCount],
|
||||
Action::CycleUiDistance});
|
||||
rows_.push_back({"Download Folder",
|
||||
kDownloadDirs[downloadDirIdx_ % kDownloadDirCount],
|
||||
Action::CycleDownloadDir});
|
||||
rows_.push_back({"Debug Statistics", showStats_ ? "On" : "Off",
|
||||
Action::ToggleStats});
|
||||
} else if (section_ == 2) {
|
||||
title_ = "Audio / Video Tracks";
|
||||
title_ = "Settings > Audio & Video Tracks";
|
||||
Row v{"Video Track",
|
||||
videoStreams_.empty()
|
||||
? "(none)"
|
||||
@@ -138,40 +170,51 @@ void SettingsMenu::refresh() {
|
||||
kLangNames[audioLangIdx_ % kLangCount],
|
||||
Action::EnterLangList});
|
||||
} else if (section_ == 3) {
|
||||
title_ = "System & Debug";
|
||||
rows_.push_back({"Debug Statistics", showStats_ ? "On" : "Off",
|
||||
Action::ToggleStats});
|
||||
rows_.push_back({"Download Folder",
|
||||
kDownloadDirs[downloadDirIdx_ % kDownloadDirCount],
|
||||
Action::CycleDownloadDir});
|
||||
title_ = "Settings > Controls & Bindings";
|
||||
auto btnLabel = [&](int func, int btn) -> std::string {
|
||||
return listeningFunc_ == func
|
||||
? std::string("[ press a button ]")
|
||||
: std::string(kButtonNames[btn % kButtonCount]);
|
||||
};
|
||||
rows_.push_back({"Play / Pause", btnLabel(0, bindPlayBtn_),
|
||||
Action::ListenPlay});
|
||||
rows_.push_back({"Open Menu / Browser", btnLabel(1, bindMenuBtn_),
|
||||
Action::ListenMenu});
|
||||
rows_.push_back({"Recenter View", btnLabel(2, bindRecenterBtn_),
|
||||
Action::ListenRecenter});
|
||||
rows_.push_back({"Select / Click", "Trigger", Action::None});
|
||||
rows_.push_back({"Controller Models", drawModels_ ? "On" : "Off",
|
||||
Action::ToggleModels});
|
||||
} else if (section_ == 10) {
|
||||
title_ = "Select Video Track";
|
||||
title_ = "Settings > Select Video Track";
|
||||
for (std::size_t i = 0; i < videoStreams_.size(); ++i) {
|
||||
Row r{videoStreams_[i].second,
|
||||
static_cast<int>(i) == videoSel_ ? "<" : "",
|
||||
static_cast<int>(i) == videoSel_ ? "ACTIVE" : "Select",
|
||||
Action::PickVideoStream};
|
||||
r.data = static_cast<int>(i);
|
||||
rows_.push_back(r);
|
||||
}
|
||||
rows_.push_back({"< Back to Tracks", "", Action::Back});
|
||||
} else if (section_ == 11) {
|
||||
title_ = "Select Audio Track";
|
||||
title_ = "Settings > Select Audio Track";
|
||||
for (std::size_t i = 0; i < audioStreams_.size(); ++i) {
|
||||
Row r{audioStreams_[i].second,
|
||||
static_cast<int>(i) == audioSel_ ? "<" : "",
|
||||
static_cast<int>(i) == audioSel_ ? "ACTIVE" : "Select",
|
||||
Action::PickAudioStream};
|
||||
r.data = static_cast<int>(i);
|
||||
rows_.push_back(r);
|
||||
}
|
||||
rows_.push_back({"< Back to Tracks", "", Action::Back});
|
||||
} else { // section_ == 12: preferred-language list
|
||||
title_ = "Preferred Audio Language";
|
||||
title_ = "Settings > Preferred Audio Language";
|
||||
for (int i = 0; i < kLangCount; ++i) {
|
||||
Row r{kLangNames[i], i == audioLangIdx_ ? "<" : "",
|
||||
Row r{kLangNames[i], i == audioLangIdx_ ? "ACTIVE" : "Select",
|
||||
Action::PickLanguage};
|
||||
r.data = i;
|
||||
rows_.push_back(r);
|
||||
}
|
||||
rows_.push_back({"< Back to Tracks", "", Action::Back});
|
||||
}
|
||||
rows_.push_back({"< Back", "", Action::Back});
|
||||
}
|
||||
|
||||
SettingsMenu::Action SettingsMenu::activate() {
|
||||
@@ -188,7 +231,7 @@ SettingsMenu::Action SettingsMenu::activate() {
|
||||
case Action::EnterAudioList: section_ = 11; selected_ = 0; refresh(); break;
|
||||
case Action::EnterLangList: section_ = 12; selected_ = 0; refresh(); break;
|
||||
case Action::Back:
|
||||
section_ = (section_ >= 10) ? 2 : -1; // list -> Tracks; else -> root
|
||||
section_ = (section_ >= 10) ? 2 : 0; // list -> Tracks; else -> Display
|
||||
selected_ = 0;
|
||||
refresh();
|
||||
break;
|
||||
@@ -243,6 +286,31 @@ SettingsMenu::Action SettingsMenu::activate() {
|
||||
saveConfig();
|
||||
refresh();
|
||||
break;
|
||||
case Action::CycleCurvature:
|
||||
curvatureIdx_ = (curvatureIdx_ + 1) % kCurvatureCount;
|
||||
saveConfig();
|
||||
refresh();
|
||||
break;
|
||||
case Action::CycleUiTextScale:
|
||||
uiTextScaleIdx_ = (uiTextScaleIdx_ + 1) % kUiTextScaleCount;
|
||||
saveConfig();
|
||||
refresh();
|
||||
break;
|
||||
case Action::CycleUiTextColor:
|
||||
uiTextColorIdx_ = (uiTextColorIdx_ + 1) % kUiTextColorCount;
|
||||
saveConfig();
|
||||
refresh();
|
||||
break;
|
||||
case Action::CycleUiAccentColor:
|
||||
uiAccentColorIdx_ = (uiAccentColorIdx_ + 1) % kUiAccentColorCount;
|
||||
saveConfig();
|
||||
refresh();
|
||||
break;
|
||||
case Action::CycleUiDistance:
|
||||
uiDistanceIdx_ = (uiDistanceIdx_ + 1) % kUiDistanceCount;
|
||||
saveConfig();
|
||||
refresh();
|
||||
break;
|
||||
case Action::PickVideoStream:
|
||||
videoSel_ = rows_[selected_].data;
|
||||
section_ = 2;
|
||||
@@ -323,6 +391,24 @@ std::string SettingsMenu::downloadDir() const {
|
||||
return d[0] == '~' ? base + (d + 1) : std::string(d);
|
||||
}
|
||||
|
||||
float SettingsMenu::screenCurvature() const {
|
||||
return kCurvatureValues[curvatureIdx_ % kCurvatureCount];
|
||||
}
|
||||
|
||||
void SettingsMenu::setCurvatureIdx(int idx) {
|
||||
curvatureIdx_ = ((idx % kCurvatureCount) + kCurvatureCount) % kCurvatureCount;
|
||||
saveConfig();
|
||||
refresh();
|
||||
}
|
||||
|
||||
float SettingsMenu::uiTextScale() const {
|
||||
return kUiTextScaleValues[uiTextScaleIdx_ % kUiTextScaleCount];
|
||||
}
|
||||
|
||||
float SettingsMenu::uiDistance() const {
|
||||
return kUiDistanceValues[uiDistanceIdx_ % kUiDistanceCount];
|
||||
}
|
||||
|
||||
void SettingsMenu::loadConfig() {
|
||||
const std::string path = getConfigPath();
|
||||
std::ifstream file(path);
|
||||
@@ -343,9 +429,19 @@ void SettingsMenu::loadConfig() {
|
||||
else if (key == "distance") screenDistance_ = std::clamp(std::stof(val), 1.0f, 10.0f);
|
||||
else if (key == "bind_play_btn") bindPlayBtn_ = std::clamp(std::stoi(val), 0, kButtonCount - 1);
|
||||
else if (key == "bind_menu_btn") bindMenuBtn_ = std::clamp(std::stoi(val), 0, kButtonCount - 1);
|
||||
else if (key == "bind_recenter_btn") bindRecenterBtn_ = std::clamp(std::stoi(val), 0, kButtonCount - 1);
|
||||
else if (key == "bind_recenter_btn") {
|
||||
const int b = std::clamp(std::stoi(val), 0, kButtonCount - 1);
|
||||
// Grip used to default to recenter; it now toggles the laser pointer,
|
||||
// so a saved grip binding is migrated to the new default (Bumper).
|
||||
bindRecenterBtn_ = (b == 8) ? 6 : b;
|
||||
}
|
||||
else if (key == "download_dir") downloadDirIdx_ = std::clamp(std::stoi(val), 0, kDownloadDirCount - 1);
|
||||
else if (key == "audio_lang") audioLangIdx_ = std::clamp(std::stoi(val), 0, kLangCount - 1);
|
||||
else if (key == "curvature") curvatureIdx_ = std::clamp(std::stoi(val), 0, kCurvatureCount - 1);
|
||||
else if (key == "ui_text_scale") uiTextScaleIdx_ = std::clamp(std::stoi(val), 0, kUiTextScaleCount - 1);
|
||||
else if (key == "ui_text_color") uiTextColorIdx_ = std::clamp(std::stoi(val), 0, kUiTextColorCount - 1);
|
||||
else if (key == "ui_accent_color") uiAccentColorIdx_ = std::clamp(std::stoi(val), 0, kUiAccentColorCount - 1);
|
||||
else if (key == "ui_distance") uiDistanceIdx_ = std::clamp(std::stoi(val), 0, kUiDistanceCount - 1);
|
||||
}
|
||||
PIXELVR_LOG_INFO("Loaded settings from %s", path.c_str());
|
||||
}
|
||||
@@ -370,6 +466,11 @@ void SettingsMenu::saveConfig() const {
|
||||
file << "bind_recenter_btn=" << bindRecenterBtn_ << "\n";
|
||||
file << "download_dir=" << downloadDirIdx_ << "\n";
|
||||
file << "audio_lang=" << audioLangIdx_ << "\n";
|
||||
file << "curvature=" << curvatureIdx_ << "\n";
|
||||
file << "ui_text_scale=" << uiTextScaleIdx_ << "\n";
|
||||
file << "ui_text_color=" << uiTextColorIdx_ << "\n";
|
||||
file << "ui_accent_color=" << uiAccentColorIdx_ << "\n";
|
||||
file << "ui_distance=" << uiDistanceIdx_ << "\n";
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
PIXELVR_LOG_WARN("Could not save settings: %s", e.what());
|
||||
|
||||
@@ -25,6 +25,11 @@ public:
|
||||
ToggleModels,
|
||||
ToggleStats,
|
||||
CycleDistance,
|
||||
CycleCurvature,
|
||||
CycleUiTextScale,
|
||||
CycleUiTextColor,
|
||||
CycleUiAccentColor,
|
||||
CycleUiDistance,
|
||||
EnterVideoList,
|
||||
EnterAudioList,
|
||||
EnterLangList,
|
||||
@@ -77,6 +82,20 @@ public:
|
||||
std::string downloadDir() const;
|
||||
float screenDistance() const { return screenDistance_; }
|
||||
void setScreenDistance(float d) { screenDistance_ = d; refresh(); }
|
||||
float screenCurvature() const;
|
||||
void setCurvatureIdx(int idx);
|
||||
int curvatureIdx() const { return curvatureIdx_; }
|
||||
float uiTextScale() const;
|
||||
int uiTextColorIdx() const { return uiTextColorIdx_; }
|
||||
int uiAccentColorIdx() const { return uiAccentColorIdx_; }
|
||||
float uiDistance() const;
|
||||
|
||||
int section() const { return section_; }
|
||||
void setSection(int s) {
|
||||
section_ = s;
|
||||
selected_ = 0;
|
||||
refresh();
|
||||
}
|
||||
|
||||
// Track lists (container index + label) for the current media, pushed by the
|
||||
// app on load. selected*Stream() return the chosen container index (-1 none).
|
||||
@@ -109,11 +128,16 @@ private:
|
||||
// Function -> physical button (XrInputState::Button index); press-to-bind.
|
||||
int bindPlayBtn_ = 0; // default A
|
||||
int bindMenuBtn_ = 1; // default B
|
||||
int bindRecenterBtn_ = 8; // default Grip
|
||||
int bindRecenterBtn_ = 6; // default Bumper (grip toggles the laser)
|
||||
int listeningFunc_ = -1; // 0=play 1=menu 2=recenter while binding, else -1
|
||||
int downloadDirIdx_ = 0; // index into the download-destination presets
|
||||
int curvatureIdx_ = 0; // 0=Flat (0%), 1=Gentle (25%), 2=Cinema (50%), 3=Deep (75%), 4=IMAX (100%)
|
||||
int uiTextScaleIdx_ = 2; // 0=90%, 1=105%, 2=120% (default), 3=140%, 4=160%
|
||||
int uiTextColorIdx_ = 0; // 0=Pure White, 1=Ice Cyan, 2=Warm Cream, 3=Soft Gold, 4=Mint Green
|
||||
int uiAccentColorIdx_ = 0; // 0=Cyber Cyan, 1=Emerald, 2=Amber Gold, 3=Royal Purple, 4=Crimson
|
||||
int uiDistanceIdx_ = 1; // 0=Near (1.5m), 1=Comfort (1.9m), 2=Mid (2.3m), 3=Far (2.8m)
|
||||
|
||||
int section_ = -1; // -1 = top-level section list; 0..3 = a section's rows
|
||||
int section_ = 0; // 0=Display, 1=Controls, 2=Streams, 3=System; 10..12=sub-lists
|
||||
std::vector<std::pair<int, std::string>> videoStreams_; // (index, label)
|
||||
std::vector<std::pair<int, std::string>> audioStreams_;
|
||||
int videoSel_ = 0; // position within videoStreams_
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
#include "ui/thumbnail_cache.hpp"
|
||||
#include "util/logging.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
|
||||
#ifdef PIXELVR_HAVE_MEDIA
|
||||
extern "C" {
|
||||
#include <libavcodec/avcodec.h>
|
||||
#include <libavformat/avformat.h>
|
||||
#include <libavutil/imgutils.h>
|
||||
#include <libswscale/swscale.h>
|
||||
}
|
||||
#endif
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
namespace pixelvr {
|
||||
|
||||
constexpr int kThumbW = 256;
|
||||
constexpr int kThumbH = 144;
|
||||
constexpr std::size_t kThumbBytes = kThumbW * kThumbH * 4;
|
||||
|
||||
struct DiskMetaHeader {
|
||||
uint32_t magic = 0x5056524D; // 'PVRM'
|
||||
int32_t width = 0;
|
||||
int32_t height = 0;
|
||||
double fps = 0.0;
|
||||
double durationSec = 0.0;
|
||||
};
|
||||
|
||||
static uint64_t fnv1a64(const std::string& str) {
|
||||
uint64_t hash = 14695981039346656037ULL;
|
||||
for (char c : str) {
|
||||
hash ^= static_cast<uint8_t>(c);
|
||||
hash *= 1099511628211ULL;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
ThumbnailCache::ThumbnailCache() {
|
||||
const char* home = std::getenv("HOME");
|
||||
std::string base = (home != nullptr) ? home : "/home/steamos";
|
||||
cacheDir_ = base + "/.cache/pixelvr/thumbs";
|
||||
std::error_code ec;
|
||||
fs::create_directories(cacheDir_, ec);
|
||||
|
||||
worker_ = std::thread([this] { workerLoop(); });
|
||||
}
|
||||
|
||||
ThumbnailCache::~ThumbnailCache() {
|
||||
stop_.store(true);
|
||||
cv_.notify_all();
|
||||
if (worker_.joinable()) {
|
||||
worker_.join();
|
||||
}
|
||||
}
|
||||
|
||||
void ThumbnailCache::request(const std::string& videoPath) {
|
||||
if (videoPath.empty() || videoPath[0] != '/') return;
|
||||
std::lock_guard<std::mutex> lk(mutex_);
|
||||
if (queuedPaths_.find(videoPath) != queuedPaths_.end() ||
|
||||
cache_.find(videoPath) != cache_.end()) {
|
||||
return;
|
||||
}
|
||||
queuedPaths_[videoPath] = true;
|
||||
queue_.push(videoPath);
|
||||
cv_.notify_one();
|
||||
}
|
||||
|
||||
bool ThumbnailCache::get(const std::string& videoPath, std::vector<uint8_t>& outRgba) {
|
||||
if (videoPath.empty()) return false;
|
||||
std::lock_guard<std::mutex> lk(mutex_);
|
||||
auto it = cache_.find(videoPath);
|
||||
if (it != cache_.end() && it->second.size() == kThumbBytes) {
|
||||
outRgba = it->second;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool ThumbnailCache::getMeta(const std::string& videoPath, MediaMeta& outMeta) {
|
||||
if (videoPath.empty()) return false;
|
||||
std::lock_guard<std::mutex> lk(mutex_);
|
||||
auto it = metaCache_.find(videoPath);
|
||||
if (it != metaCache_.end() && it->second.ready) {
|
||||
outMeta = it->second;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool ThumbnailCache::extractThumbnailAndMeta(const std::string& videoPath,
|
||||
std::vector<uint8_t>& outRgba,
|
||||
MediaMeta& outMeta) {
|
||||
#ifdef PIXELVR_HAVE_MEDIA
|
||||
AVFormatContext* fmt = nullptr;
|
||||
if (avformat_open_input(&fmt, videoPath.c_str(), nullptr, nullptr) < 0 || !fmt) {
|
||||
return false;
|
||||
}
|
||||
if (avformat_find_stream_info(fmt, nullptr) < 0) {
|
||||
avformat_close_input(&fmt);
|
||||
return false;
|
||||
}
|
||||
|
||||
int vIdx = av_find_best_stream(fmt, AVMEDIA_TYPE_VIDEO, -1, -1, nullptr, 0);
|
||||
if (vIdx < 0) {
|
||||
avformat_close_input(&fmt);
|
||||
return false;
|
||||
}
|
||||
|
||||
AVStream* st = fmt->streams[vIdx];
|
||||
outMeta.width = st->codecpar->width;
|
||||
outMeta.height = st->codecpar->height;
|
||||
if (st->avg_frame_rate.den > 0 && st->avg_frame_rate.num > 0) {
|
||||
outMeta.fps = av_q2d(st->avg_frame_rate);
|
||||
} else if (st->r_frame_rate.den > 0 && st->r_frame_rate.num > 0) {
|
||||
outMeta.fps = av_q2d(st->r_frame_rate);
|
||||
}
|
||||
if (fmt->duration > 0) {
|
||||
outMeta.durationSec = static_cast<double>(fmt->duration) / AV_TIME_BASE;
|
||||
} else if (st->duration > 0 && st->time_base.den > 0) {
|
||||
outMeta.durationSec = static_cast<double>(st->duration) * av_q2d(st->time_base);
|
||||
}
|
||||
outMeta.ready = true;
|
||||
|
||||
// Find standard software decoder for thumbnail extraction so we never touch V4L2/DRM hardware slots
|
||||
const AVCodec* dec = avcodec_find_decoder(st->codecpar->codec_id);
|
||||
if (dec && dec->name && std::strstr(dec->name, "v4l2") != nullptr) {
|
||||
if (st->codecpar->codec_id == AV_CODEC_ID_HEVC) {
|
||||
if (const AVCodec* sw = avcodec_find_decoder_by_name("hevc")) dec = sw;
|
||||
} else if (st->codecpar->codec_id == AV_CODEC_ID_H264) {
|
||||
if (const AVCodec* sw = avcodec_find_decoder_by_name("h264")) dec = sw;
|
||||
}
|
||||
}
|
||||
if (!dec) {
|
||||
avformat_close_input(&fmt);
|
||||
return false;
|
||||
}
|
||||
|
||||
AVCodecContext* ctx = avcodec_alloc_context3(dec);
|
||||
if (!ctx) {
|
||||
avformat_close_input(&fmt);
|
||||
return false;
|
||||
}
|
||||
avcodec_parameters_to_context(ctx, st->codecpar);
|
||||
ctx->thread_count = 2;
|
||||
ctx->skip_loop_filter = AVDISCARD_ALL;
|
||||
if (avcodec_open2(ctx, dec, nullptr) < 0) {
|
||||
avcodec_free_context(&ctx);
|
||||
avformat_close_input(&fmt);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Seek ~10% into the clip (clamped between 3s and 30s) to avoid black intros
|
||||
if (outMeta.durationSec > 6.0) {
|
||||
double targetSec = std::clamp(outMeta.durationSec * 0.10, 3.0, 30.0);
|
||||
int64_t ts = static_cast<int64_t>(targetSec * AV_TIME_BASE);
|
||||
av_seek_frame(fmt, -1, ts, AVSEEK_FLAG_BACKWARD);
|
||||
avcodec_flush_buffers(ctx);
|
||||
}
|
||||
|
||||
AVPacket* pkt = av_packet_alloc();
|
||||
AVFrame* frame = av_frame_alloc();
|
||||
bool gotFrame = false;
|
||||
|
||||
auto decodeAttempt = [&]() {
|
||||
int packetsRead = 0;
|
||||
while (packetsRead < 300 && av_read_frame(fmt, pkt) >= 0) {
|
||||
if (pkt->stream_index == vIdx) {
|
||||
++packetsRead;
|
||||
if (avcodec_send_packet(ctx, pkt) == 0) {
|
||||
if (avcodec_receive_frame(ctx, frame) == 0) {
|
||||
gotFrame = true;
|
||||
av_packet_unref(pkt);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
av_packet_unref(pkt);
|
||||
}
|
||||
};
|
||||
|
||||
decodeAttempt();
|
||||
if (!gotFrame && outMeta.durationSec > 6.0) {
|
||||
// Fallback to start of file if seek landed near EOF
|
||||
av_seek_frame(fmt, -1, 0, AVSEEK_FLAG_BACKWARD);
|
||||
avcodec_flush_buffers(ctx);
|
||||
decodeAttempt();
|
||||
}
|
||||
|
||||
bool ok = false;
|
||||
if (gotFrame && frame->width > 0 && frame->height > 0) {
|
||||
// For Side-by-Side (SBS) 3D or VR180 videos (aspect >= 1.95 or width == 2*height),
|
||||
// crop the left eye (left half of frame) so the thumbnail shows a normal single view
|
||||
// instead of a squished double-eye image!
|
||||
int srcW = frame->width;
|
||||
int srcH = frame->height;
|
||||
const float ar = static_cast<float>(srcW) / static_cast<float>(srcH);
|
||||
std::string lowerPath = videoPath;
|
||||
for (char& c : lowerPath) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
||||
const bool isSbs = (lowerPath.find("sbs") != std::string::npos ||
|
||||
lowerPath.find("vr180") != std::string::npos ||
|
||||
lowerPath.find("180") != std::string::npos ||
|
||||
ar >= 2.6f);
|
||||
if (isSbs && srcW >= 2 * kThumbW) {
|
||||
srcW = frame->width / 2;
|
||||
}
|
||||
|
||||
SwsContext* sws = sws_getContext(
|
||||
srcW, srcH, static_cast<AVPixelFormat>(frame->format),
|
||||
kThumbW, kThumbH, AV_PIX_FMT_RGBA,
|
||||
SWS_BILINEAR, nullptr, nullptr, nullptr);
|
||||
if (sws) {
|
||||
outRgba.resize(kThumbBytes);
|
||||
uint8_t* dstData[4] = {outRgba.data(), nullptr, nullptr, nullptr};
|
||||
int dstLinesize[4] = {kThumbW * 4, 0, 0, 0};
|
||||
sws_scale(sws, frame->data, frame->linesize, 0, srcH, dstData, dstLinesize);
|
||||
sws_freeContext(sws);
|
||||
ok = true;
|
||||
}
|
||||
}
|
||||
|
||||
av_frame_free(&frame);
|
||||
av_packet_free(&pkt);
|
||||
avcodec_free_context(&ctx);
|
||||
avformat_close_input(&fmt);
|
||||
return ok;
|
||||
#else
|
||||
(void)videoPath;
|
||||
(void)outRgba;
|
||||
(void)outMeta;
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
void ThumbnailCache::workerLoop() {
|
||||
while (!stop_.load()) {
|
||||
std::string path;
|
||||
{
|
||||
std::unique_lock<std::mutex> lk(mutex_);
|
||||
cv_.wait(lk, [this] { return stop_.load() || !queue_.empty(); });
|
||||
if (stop_.load()) break;
|
||||
path = queue_.front();
|
||||
queue_.pop();
|
||||
}
|
||||
|
||||
if (path.empty() || path[0] != '/') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const std::string baseHash = std::to_string(fnv1a64(path));
|
||||
const std::string cacheFile = cacheDir_ + "/" + baseHash + ".rgba";
|
||||
const std::string metaFile = cacheDir_ + "/" + baseHash + ".meta";
|
||||
|
||||
std::error_code ec;
|
||||
if (fs::is_regular_file(cacheFile, ec) && fs::file_size(cacheFile, ec) == kThumbBytes) {
|
||||
std::ifstream in(cacheFile, std::ios::binary);
|
||||
std::vector<uint8_t> data(kThumbBytes);
|
||||
if (in.read(reinterpret_cast<char*>(data.data()), kThumbBytes)) {
|
||||
MediaMeta meta{};
|
||||
if (fs::is_regular_file(metaFile, ec) &&
|
||||
fs::file_size(metaFile, ec) == sizeof(DiskMetaHeader)) {
|
||||
std::ifstream min(metaFile, std::ios::binary);
|
||||
DiskMetaHeader hdr{};
|
||||
if (min.read(reinterpret_cast<char*>(&hdr), sizeof(hdr)) &&
|
||||
hdr.magic == 0x5056524D) {
|
||||
meta.width = hdr.width;
|
||||
meta.height = hdr.height;
|
||||
meta.fps = hdr.fps;
|
||||
meta.durationSec = hdr.durationSec;
|
||||
meta.ready = true;
|
||||
}
|
||||
}
|
||||
if (!meta.ready) {
|
||||
// Quick metadata probe if .meta was not cached yet
|
||||
std::vector<uint8_t> dummy;
|
||||
extractThumbnailAndMeta(path, dummy, meta);
|
||||
if (meta.ready) {
|
||||
DiskMetaHeader hdr{0x5056524D, meta.width, meta.height, meta.fps, meta.durationSec};
|
||||
std::ofstream mout(metaFile, std::ios::binary);
|
||||
mout.write(reinterpret_cast<const char*>(&hdr), sizeof(hdr));
|
||||
}
|
||||
}
|
||||
std::lock_guard<std::mutex> lk(mutex_);
|
||||
cache_[path] = std::move(data);
|
||||
if (meta.ready) metaCache_[path] = meta;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<uint8_t> rgba;
|
||||
MediaMeta meta{};
|
||||
const bool ok = extractThumbnailAndMeta(path, rgba, meta);
|
||||
if (meta.ready) {
|
||||
DiskMetaHeader hdr{0x5056524D, meta.width, meta.height, meta.fps, meta.durationSec};
|
||||
std::ofstream mout(metaFile, std::ios::binary);
|
||||
mout.write(reinterpret_cast<const char*>(&hdr), sizeof(hdr));
|
||||
std::lock_guard<std::mutex> lk(mutex_);
|
||||
metaCache_[path] = meta;
|
||||
}
|
||||
if (ok && rgba.size() == kThumbBytes) {
|
||||
{
|
||||
std::ofstream out(cacheFile, std::ios::binary);
|
||||
out.write(reinterpret_cast<const char*>(rgba.data()), kThumbBytes);
|
||||
}
|
||||
std::lock_guard<std::mutex> lk(mutex_);
|
||||
cache_[path] = std::move(rgba);
|
||||
PIXELVR_LOG_INFO("Thumbnail generated for %s (%dx%d, %.0fs)",
|
||||
path.c_str(), meta.width, meta.height, meta.durationSec);
|
||||
} else {
|
||||
PIXELVR_LOG_WARN("Thumbnail generation failed for %s", path.c_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace pixelvr
|
||||
@@ -0,0 +1,54 @@
|
||||
#pragma once
|
||||
|
||||
#include <atomic>
|
||||
#include <condition_variable>
|
||||
#include <cstdint>
|
||||
#include <mutex>
|
||||
#include <queue>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
namespace pixelvr {
|
||||
|
||||
class ThumbnailCache {
|
||||
public:
|
||||
struct MediaMeta {
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
double fps = 0.0;
|
||||
double durationSec = 0.0;
|
||||
bool ready = false;
|
||||
};
|
||||
|
||||
ThumbnailCache();
|
||||
~ThumbnailCache();
|
||||
|
||||
// Requests thumbnail extraction/load and metadata probe for a path (non-blocking).
|
||||
void request(const std::string& videoPath);
|
||||
|
||||
// Returns true and copies out the 256x144 RGBA pixels if ready.
|
||||
bool get(const std::string& videoPath, std::vector<uint8_t>& outRgba);
|
||||
|
||||
// Returns true and populates MediaMeta if probed.
|
||||
bool getMeta(const std::string& videoPath, MediaMeta& outMeta);
|
||||
|
||||
private:
|
||||
void workerLoop();
|
||||
bool extractThumbnailAndMeta(const std::string& videoPath,
|
||||
std::vector<uint8_t>& outRgba,
|
||||
MediaMeta& outMeta);
|
||||
|
||||
std::string cacheDir_;
|
||||
std::mutex mutex_;
|
||||
std::condition_variable cv_;
|
||||
std::queue<std::string> queue_;
|
||||
std::unordered_map<std::string, bool> queuedPaths_;
|
||||
std::unordered_map<std::string, std::vector<uint8_t>> cache_;
|
||||
std::unordered_map<std::string, MediaMeta> metaCache_;
|
||||
std::atomic<bool> stop_{false};
|
||||
std::thread worker_;
|
||||
};
|
||||
|
||||
} // namespace pixelvr
|
||||
@@ -543,6 +543,8 @@ void XrContext::syncActions() {
|
||||
input_.thumbstickRX = 0.0f;
|
||||
input_.thumbstickRY = 0.0f;
|
||||
for (int i = 0; i < XrInputState::BtnCount; ++i) input_.buttonDown[i] = false;
|
||||
input_.gripDown[0] = false;
|
||||
input_.gripDown[1] = false;
|
||||
if (!actionsReady_) {
|
||||
return;
|
||||
}
|
||||
@@ -628,6 +630,21 @@ void XrContext::syncActions() {
|
||||
st.isActive && st.currentState && st.changedSinceLastSync;
|
||||
}
|
||||
}
|
||||
|
||||
// Per-hand grip press: `buttonDown` above aggregates both hands, but the laser
|
||||
// pointer is toggled per hand, so query the squeeze action for each subaction.
|
||||
if (buttonActions_[XrInputState::BtnGrip] != XR_NULL_HANDLE) {
|
||||
for (int h = 0; h < 2; ++h) {
|
||||
XrActionStateGetInfo gi{XR_TYPE_ACTION_STATE_GET_INFO};
|
||||
gi.action = buttonActions_[XrInputState::BtnGrip];
|
||||
gi.subactionPath = handPaths_[h];
|
||||
XrActionStateBoolean st{XR_TYPE_ACTION_STATE_BOOLEAN};
|
||||
if (XR_SUCCEEDED(xrGetActionStateBoolean(session_, &gi, &st))) {
|
||||
input_.gripDown[h] =
|
||||
st.isActive && st.currentState && st.changedSinceLastSync;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void XrContext::handleSessionStateChange(const XrEventDataSessionStateChanged& ev,
|
||||
|
||||
@@ -56,6 +56,11 @@ struct XrInputState {
|
||||
BtnDpadUp, BtnDpadDown, BtnDpadLeft, BtnDpadRight, BtnCount
|
||||
};
|
||||
bool buttonDown[BtnCount] = {};
|
||||
|
||||
// Per-hand grip (squeeze) rising edge: toggles that hand's laser pointer, so
|
||||
// one controller can drive a panel with the stick while the other points.
|
||||
// Index 0 = left, 1 = right.
|
||||
bool gripDown[2] = {false, false};
|
||||
};
|
||||
|
||||
// Owns the OpenXR instance, system, session, reference space, and swapchains,
|
||||
|
||||
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
Executable
+549
@@ -0,0 +1,549 @@
|
||||
#!/bin/sh
|
||||
# Pixel VR Player installer for the Valve Steam Frame.
|
||||
#
|
||||
# Download-and-run (no other files needed):
|
||||
# curl -fsSL https://raw.githubusercontent.com/Pixelplanet/Pixel-VR-Player/main/tools/install.sh | sh
|
||||
#
|
||||
# Or from an extracted release tarball:
|
||||
# ./install.sh
|
||||
#
|
||||
# Options:
|
||||
# --dir DIR install directory (default: ~/PixelVRPlayer)
|
||||
# --version vX.Y.Z release to install (default: the latest release)
|
||||
# --no-steam do not add the Non-Steam Game shortcut
|
||||
# --restart-steam close Steam before registering, then start it again
|
||||
# --uninstall remove the app and its Steam shortcut
|
||||
# --force install even if the CPU is not aarch64
|
||||
# -h, --help show this help
|
||||
#
|
||||
# Settings (~/.config/pixelvr) and the thumbnail cache (~/.cache/pixelvr) live
|
||||
# outside the install directory, so reinstalling or upgrading never loses them.
|
||||
set -eu
|
||||
|
||||
REPO="Pixelplanet/Pixel-VR-Player"
|
||||
ASSET="pixelvr-linux-arm64.tar.gz"
|
||||
APP_NAME="Pixel VR Player"
|
||||
|
||||
INSTALL_DIR="${PIXELVR_INSTALL_DIR:-$HOME/PixelVRPlayer}"
|
||||
VERSION=""
|
||||
DO_STEAM=1
|
||||
RESTART_STEAM=0
|
||||
UNINSTALL=0
|
||||
FORCE=0
|
||||
|
||||
if [ -t 1 ]; then
|
||||
C_INFO=$(printf '\0033[36m')
|
||||
C_OK=$(printf '\0033[32m')
|
||||
C_WARN=$(printf '\0033[33m')
|
||||
C_ERR=$(printf '\0033[31m')
|
||||
C_OFF=$(printf '\0033[0m')
|
||||
else
|
||||
C_INFO=""; C_OK=""; C_WARN=""; C_ERR=""; C_OFF=""
|
||||
fi
|
||||
|
||||
info() { printf '%s==>%s %s\n' "$C_INFO" "$C_OFF" "$*"; }
|
||||
ok() { printf '%s ok%s %s\n' "$C_OK" "$C_OFF" "$*"; }
|
||||
warn() { printf '%swarn%s %s\n' "$C_WARN" "$C_OFF" "$*" >&2; }
|
||||
die() { printf '%serror%s %s\n' "$C_ERR" "$C_OFF" "$*" >&2; exit 1; }
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Pixel VR Player installer for the Valve Steam Frame.
|
||||
|
||||
Usage:
|
||||
curl -fsSL https://raw.githubusercontent.com/Pixelplanet/Pixel-VR-Player/main/tools/install.sh | sh
|
||||
./install.sh [options]
|
||||
|
||||
Options:
|
||||
--dir DIR install directory (default: ~/PixelVRPlayer)
|
||||
--version vX.Y.Z release to install (default: the latest release)
|
||||
--no-steam do not add the Non-Steam Game shortcut
|
||||
--restart-steam close Steam before registering, then start it again
|
||||
--uninstall remove the app and its Steam shortcut
|
||||
--force install even if the CPU is not aarch64
|
||||
-h, --help show this help
|
||||
|
||||
Settings in ~/.config/pixelvr and cached thumbnails in ~/.cache/pixelvr are
|
||||
never touched by install, upgrade or uninstall.
|
||||
USAGE
|
||||
}
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--dir)
|
||||
[ $# -ge 2 ] || die "--dir needs a value"
|
||||
case "$2" in
|
||||
"~/"*) INSTALL_DIR="$HOME/${2#\~/}" ;;
|
||||
*) INSTALL_DIR="$2" ;;
|
||||
esac
|
||||
shift ;;
|
||||
--dir=*) INSTALL_DIR="${1#--dir=}" ; case "$INSTALL_DIR" in "~/"*) INSTALL_DIR="$HOME/${INSTALL_DIR#\~/}" ;; esac ;;
|
||||
--version)
|
||||
[ $# -ge 2 ] || die "--version needs a value"
|
||||
VERSION="$2"; shift ;;
|
||||
--version=*) VERSION="${1#--version=}" ;;
|
||||
--no-steam) DO_STEAM=0 ;;
|
||||
--restart-steam) RESTART_STEAM=1 ;;
|
||||
--uninstall) UNINSTALL=1 ;;
|
||||
--force) FORCE=1 ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) die "unknown option: $1 (try --help)" ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
case "$INSTALL_DIR" in
|
||||
""|"/"|".") die "refusing to use '$INSTALL_DIR' as install directory" ;;
|
||||
esac
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Steam shortcut registration (non-Steam game in the library)
|
||||
# ---------------------------------------------------------------------------
|
||||
steam_running() {
|
||||
command -v pgrep >/dev/null 2>&1 && pgrep -x steam >/dev/null 2>&1
|
||||
}
|
||||
|
||||
find_steam_userdata() {
|
||||
steam_root=""
|
||||
for candidate in "$HOME/.steam/steam" "$HOME/.local/share/Steam" "$HOME/.steam/root"; do
|
||||
if [ -d "$candidate/userdata" ]; then
|
||||
steam_root="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
[ -n "$steam_root" ] || return 1
|
||||
|
||||
vdf=""
|
||||
newest=0
|
||||
for dir in "$steam_root"/userdata/*; do
|
||||
case "${dir##*/}" in ''|*[!0-9]*) continue ;; esac
|
||||
file="$dir/config/shortcuts.vdf"
|
||||
[ -f "$file" ] || continue
|
||||
stamp=$(stat -c %Y "$file" 2>/dev/null || stat -f %m "$file" 2>/dev/null || echo 0)
|
||||
if [ -z "$vdf" ] || [ "$stamp" -ge "$newest" ]; then
|
||||
vdf="$file"
|
||||
newest="$stamp"
|
||||
fi
|
||||
done
|
||||
if [ -z "$vdf" ]; then
|
||||
# No shortcuts.vdf yet: create it for the most recently used account.
|
||||
for dir in "$steam_root"/userdata/*; do
|
||||
case "${dir##*/}" in ''|*[!0-9]*) continue ;; esac
|
||||
[ -d "$dir/config" ] || continue
|
||||
stamp=$(stat -c %Y "$dir/config" 2>/dev/null || stat -f %m "$dir/config" 2>/dev/null || echo 0)
|
||||
if [ -z "$vdf" ] || [ "$stamp" -ge "$newest" ]; then
|
||||
vdf="$dir/config/shortcuts.vdf"
|
||||
newest="$stamp"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
[ -n "$vdf" ] || return 1
|
||||
printf '%s\n' "$vdf"
|
||||
}
|
||||
|
||||
# Binary KeyValues editor for shortcuts.vdf. Arguments:
|
||||
# <add|remove> <vdf path> <app name> <exe> <start dir>
|
||||
run_vdf_helper() {
|
||||
python3 - "$@" <<'PYEOF'
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
import zlib
|
||||
|
||||
OBJ, STR, INT, U64, END = 0x00, 0x01, 0x02, 0x07, 0x08
|
||||
|
||||
|
||||
class Unsupported(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def read_cstr(buf, pos):
|
||||
end = buf.find(b"\0", pos)
|
||||
if end < 0:
|
||||
raise ValueError("unterminated string")
|
||||
return buf[pos:end].decode("utf-8", "surrogateescape"), end + 1
|
||||
|
||||
|
||||
def parse_obj(buf, pos):
|
||||
entries = {}
|
||||
while True:
|
||||
if pos >= len(buf):
|
||||
raise ValueError("truncated object")
|
||||
kind = buf[pos]
|
||||
if kind in (END, 0x0B):
|
||||
return entries, pos + 1
|
||||
key, pos = read_cstr(buf, pos + 1)
|
||||
if kind == OBJ:
|
||||
value, pos = parse_obj(buf, pos)
|
||||
elif kind == STR:
|
||||
value, pos = read_cstr(buf, pos)
|
||||
elif kind == INT:
|
||||
value = struct.unpack_from("<i", buf, pos)[0]
|
||||
pos += 4
|
||||
elif kind == U64:
|
||||
value = struct.unpack_from("<Q", buf, pos)[0]
|
||||
pos += 8
|
||||
else:
|
||||
raise Unsupported("unsupported type 0x%02x at key %r" % (kind, key))
|
||||
entries[key] = [kind, value]
|
||||
return entries, pos
|
||||
|
||||
|
||||
def parse_file(buf):
|
||||
if not buf:
|
||||
return {"shortcuts": [OBJ, {}]}
|
||||
if buf[0] != OBJ:
|
||||
raise Unsupported("not a binary KeyValues file")
|
||||
key, pos = read_cstr(buf, 1)
|
||||
value, _ = parse_obj(buf, pos)
|
||||
return {key: [OBJ, value]}
|
||||
|
||||
|
||||
def dump(entries):
|
||||
out = bytearray()
|
||||
for key, (kind, value) in entries.items():
|
||||
kb = key.encode("utf-8", "surrogateescape")
|
||||
if kind == OBJ:
|
||||
out += bytes([OBJ]) + kb + b"\0" + dump(value) + bytes([END])
|
||||
elif kind == STR:
|
||||
out += bytes([STR]) + kb + b"\0" + value.encode("utf-8", "surrogateescape") + b"\0"
|
||||
elif kind == INT:
|
||||
out += bytes([INT]) + kb + b"\0" + struct.pack("<i", value)
|
||||
elif kind == U64:
|
||||
out += bytes([U64]) + kb + b"\0" + struct.pack("<Q", value)
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def app_id(exe, name):
|
||||
# Steam derives the shortcut id from the quoted exe path plus the name.
|
||||
return (zlib.crc32((exe + name).encode("utf-8")) | 0x80000000) & 0xFFFFFFFF
|
||||
|
||||
|
||||
def to_i32(value):
|
||||
return value - 0x100000000 if value >= 0x80000000 else value
|
||||
|
||||
|
||||
def find(shortcuts, name, exe):
|
||||
for key in shortcuts:
|
||||
kind, entry = shortcuts[key]
|
||||
if kind != OBJ:
|
||||
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:
|
||||
return key, entry
|
||||
return None, None
|
||||
|
||||
|
||||
def main():
|
||||
mode, path, name = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||
exe = sys.argv[4] if len(sys.argv) > 4 else ""
|
||||
start_dir = sys.argv[5] if len(sys.argv) > 5 else ""
|
||||
|
||||
buf = b""
|
||||
if os.path.exists(path):
|
||||
with open(path, "rb") as handle:
|
||||
buf = handle.read()
|
||||
root = parse_file(buf)
|
||||
shortcuts = root["shortcuts"][1]
|
||||
|
||||
key, entry = find(shortcuts, name, exe)
|
||||
|
||||
if mode == "remove":
|
||||
if entry is None:
|
||||
print("no matching shortcut, nothing to remove")
|
||||
return 0
|
||||
del shortcuts[key]
|
||||
renumbered = {}
|
||||
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
|
||||
print("removed '%s'" % name)
|
||||
else:
|
||||
quoted_exe = '"%s"' % exe
|
||||
created = entry is None
|
||||
if created:
|
||||
index = 0
|
||||
for existing in shortcuts:
|
||||
if existing.isdigit() and int(existing) >= index:
|
||||
index = int(existing) + 1
|
||||
entry = {}
|
||||
shortcuts[str(index)] = [OBJ, entry]
|
||||
if "appid" not in entry:
|
||||
entry["appid"] = [INT, to_i32(app_id(quoted_exe, name))]
|
||||
for field, value in (
|
||||
("AppName", [STR, name]),
|
||||
("Exe", [STR, quoted_exe]),
|
||||
("StartDir", [STR, '"%s"' % start_dir]),
|
||||
("icon", [STR, ""]),
|
||||
("ShortcutPath", [STR, ""]),
|
||||
("LaunchOptions", [STR, ""]),
|
||||
("IsHidden", [INT, 0]),
|
||||
("AllowDesktopConfig", [INT, 1]),
|
||||
("AllowOverlay", [INT, 1]),
|
||||
("OpenVR", [INT, 1]),
|
||||
("Devkit", [INT, 0]),
|
||||
("DevkitGameID", [STR, ""]),
|
||||
("DevkitOverrideAppID", [INT, 0]),
|
||||
("LastPlayTime", [INT, 0]),
|
||||
("FlatpakAppID", [STR, ""]),
|
||||
):
|
||||
entry[field] = value
|
||||
entry.setdefault("tags", [OBJ, {}])
|
||||
print("%s '%s' (%d shortcut(s) in the library)" % (
|
||||
"added" if created else "updated", name, len(shortcuts)))
|
||||
|
||||
data = dump(root) + bytes([END])
|
||||
tmp = path + ".tmp"
|
||||
with open(tmp, "wb") as handle:
|
||||
handle.write(data)
|
||||
if os.path.exists(path):
|
||||
os.chmod(tmp, os.stat(path).st_mode & 0o777)
|
||||
os.replace(tmp, path)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
sys.exit(main())
|
||||
except Unsupported as exc:
|
||||
sys.stderr.write("cannot edit shortcuts.vdf: %s\n" % exc)
|
||||
sys.exit(3)
|
||||
except Exception as exc: # noqa: BLE001 - surface any failure to the shell
|
||||
sys.stderr.write("cannot edit shortcuts.vdf: %s\n" % exc)
|
||||
sys.exit(1)
|
||||
PYEOF
|
||||
}
|
||||
|
||||
register_steam_shortcut() {
|
||||
vdf=$(find_steam_userdata) || {
|
||||
warn "Steam userdata not found - is Steam installed and logged in at least once?"
|
||||
return 1
|
||||
}
|
||||
if ! command -v python3 >/dev/null 2>&1; then
|
||||
warn "python3 not found - skipping Steam registration"
|
||||
return 1
|
||||
fi
|
||||
|
||||
steam_was_running=0
|
||||
if steam_running; then
|
||||
steam_was_running=1
|
||||
if [ "$RESTART_STEAM" -eq 1 ]; then
|
||||
info "Closing Steam so the new shortcut is kept..."
|
||||
steam -shutdown >/dev/null 2>&1 || true
|
||||
waited=0
|
||||
while steam_running && [ "$waited" -lt 30 ]; do
|
||||
sleep 1
|
||||
waited=$((waited + 1))
|
||||
done
|
||||
if steam_running; then
|
||||
warn "Steam did not close in time"
|
||||
fi
|
||||
else
|
||||
warn "Steam is running - restart Steam afterwards to see '$APP_NAME' in the library"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -f "$vdf" ]; then
|
||||
backup="$vdf.bak.$(date +%Y%m%d%H%M%S)"
|
||||
cp "$vdf" "$backup"
|
||||
else
|
||||
backup=""
|
||||
fi
|
||||
|
||||
if ! run_vdf_helper add "$vdf" "$APP_NAME" "$INSTALL_DIR/run.sh" "$INSTALL_DIR"; then
|
||||
[ -n "$backup" ] && [ -f "$backup" ] && cp "$backup" "$vdf"
|
||||
return 1
|
||||
fi
|
||||
[ -n "$backup" ] && ok "shortcuts.vdf backed up to ${backup}"
|
||||
|
||||
if [ "$RESTART_STEAM" -eq 1 ] && [ "$steam_was_running" -eq 1 ] && ! steam_running; then
|
||||
info "Starting Steam again..."
|
||||
if command -v steam >/dev/null 2>&1; then
|
||||
nohup steam >/dev/null 2>&1 &
|
||||
fi
|
||||
fi
|
||||
STEAM_REGISTERED=1
|
||||
}
|
||||
|
||||
unregister_steam_shortcut() {
|
||||
vdf=$(find_steam_userdata) || return 1
|
||||
[ -f "$vdf" ] || return 1
|
||||
command -v python3 >/dev/null 2>&1 || return 1
|
||||
run_vdf_helper remove "$vdf" "$APP_NAME" "" "" || return 1
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Uninstall
|
||||
# ---------------------------------------------------------------------------
|
||||
if [ "$UNINSTALL" -eq 1 ]; then
|
||||
info "Removing Pixel VR Player from $INSTALL_DIR"
|
||||
if [ "$DO_STEAM" -eq 1 ]; then
|
||||
if unregister_steam_shortcut; then
|
||||
ok "removed the Steam shortcut"
|
||||
else
|
||||
warn "could not remove the Steam shortcut; delete it in Steam manually"
|
||||
fi
|
||||
fi
|
||||
rm -rf "$INSTALL_DIR"
|
||||
ok "removed $INSTALL_DIR"
|
||||
printf '\nSettings (%s/.config/pixelvr) and cached thumbnails were kept.\n' "$HOME"
|
||||
printf 'Delete them too with: rm -rf ~/.config/pixelvr ~/.cache/pixelvr\n'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Platform checks
|
||||
# ---------------------------------------------------------------------------
|
||||
machine=$(uname -m)
|
||||
case "$machine" in
|
||||
aarch64|arm64) ok "CPU: $machine" ;;
|
||||
*)
|
||||
if [ "$FORCE" -eq 0 ]; then
|
||||
die "this release is built for the Steam Frame (aarch64), not '$machine'.
|
||||
Build from source for other machines, or pass --force."
|
||||
fi
|
||||
warn "installing an aarch64 build on '$machine' (--force)" ;;
|
||||
esac
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fetch (reused by the local and download paths)
|
||||
# ---------------------------------------------------------------------------
|
||||
fetch() { # <url> <dest>
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
curl -fsSL "$1" -o "$2"
|
||||
elif command -v wget >/dev/null 2>&1; then
|
||||
wget -qO "$2" "$1"
|
||||
else
|
||||
die "need curl or wget to download the release"
|
||||
fi
|
||||
}
|
||||
|
||||
script_dir=""
|
||||
if [ -f "$0" ]; then
|
||||
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" 2>/dev/null && pwd) || script_dir=""
|
||||
fi
|
||||
|
||||
tmp=""
|
||||
cleanup() { [ -z "$tmp" ] || rm -rf "$tmp"; }
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
if [ -n "$script_dir" ] && [ -f "$script_dir/pixelvr" ] && [ -d "$script_dir/shaders" ]; then
|
||||
info "Installing from $script_dir"
|
||||
srcdir="$script_dir"
|
||||
if [ -f "$script_dir/VERSION" ]; then
|
||||
VERSION="$(cat "$script_dir/VERSION")"
|
||||
fi
|
||||
if [ -n "$VERSION" ]; then
|
||||
version_label="$VERSION"
|
||||
else
|
||||
version_label="local build"
|
||||
fi
|
||||
else
|
||||
case "$VERSION" in
|
||||
"") base="https://github.com/$REPO/releases/latest/download"
|
||||
version_label="latest" ;;
|
||||
v*) base="https://github.com/$REPO/releases/download/$VERSION"
|
||||
version_label="$VERSION" ;;
|
||||
*) VERSION="v$VERSION"
|
||||
base="https://github.com/$REPO/releases/download/$VERSION"
|
||||
version_label="$VERSION" ;;
|
||||
esac
|
||||
|
||||
tmp=$(mktemp -d) || die "cannot create a temporary directory"
|
||||
info "Downloading $version_label for aarch64"
|
||||
fetch "$base/$ASSET" "$tmp/$ASSET" || die "download failed: $base/$ASSET"
|
||||
|
||||
if fetch "$base/$ASSET.sha256" "$tmp/$ASSET.sha256" 2>/dev/null; then
|
||||
expected=$(awk 'NR == 1 { print $1 }' "$tmp/$ASSET.sha256")
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
actual=$(sha256sum "$tmp/$ASSET" | awk '{ print $1 }')
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
actual=$(shasum -a 256 "$tmp/$ASSET" | awk '{ print $1 }')
|
||||
else
|
||||
actual=""
|
||||
fi
|
||||
if [ -n "$actual" ]; then
|
||||
[ "$actual" = "$expected" ] || die "checksum mismatch for $ASSET"
|
||||
ok "checksum verified"
|
||||
fi
|
||||
else
|
||||
warn "no checksum published for this release"
|
||||
fi
|
||||
|
||||
mkdir -p "$tmp/unpacked"
|
||||
tar -xzf "$tmp/$ASSET" -C "$tmp/unpacked" || die "cannot unpack $ASSET"
|
||||
srcdir="$tmp/unpacked/PixelVRPlayer"
|
||||
[ -x "$srcdir/pixelvr" ] || [ -f "$srcdir/pixelvr" ] || die "unexpected archive layout"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Install
|
||||
# ---------------------------------------------------------------------------
|
||||
info "Installing to $INSTALL_DIR"
|
||||
mkdir -p "$INSTALL_DIR/shaders"
|
||||
rm -f "$INSTALL_DIR/pixelvr"
|
||||
cp "$srcdir/pixelvr" "$INSTALL_DIR/pixelvr"
|
||||
chmod 0755 "$INSTALL_DIR/pixelvr"
|
||||
rm -f "$INSTALL_DIR/shaders/"*.spv
|
||||
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
|
||||
cp "$script_dir/install.sh" "$INSTALL_DIR/install.sh"
|
||||
chmod 0755 "$INSTALL_DIR/install.sh"
|
||||
INSTALLED_SCRIPT=1
|
||||
fi
|
||||
if [ -n "$VERSION" ]; then
|
||||
printf '%s\n' "$VERSION" > "$INSTALL_DIR/VERSION"
|
||||
fi
|
||||
ok "$(ls -1 "$INSTALL_DIR/shaders" | wc -l | tr -d ' ') shaders, $(du -h "$INSTALL_DIR/pixelvr" 2>/dev/null | cut -f1) binary"
|
||||
|
||||
missing_libs=""
|
||||
if command -v ldd >/dev/null 2>&1; then
|
||||
missing_libs=$(ldd "$INSTALL_DIR/pixelvr" 2>/dev/null | awk '/not found/ { print $1 }' \
|
||||
| sort -u | tr '\n' ' ' || true)
|
||||
fi
|
||||
if [ -n "$missing_libs" ]; then
|
||||
warn "missing shared libraries: $missing_libs"
|
||||
warn "if these are libav* libraries, the system FFmpeg is too old"
|
||||
fi
|
||||
|
||||
if [ ! -f /opt/steamvr/steamxr_linuxarm64.json ]; then
|
||||
warn "SteamVR not found - install it from Steam before launching"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Steam library entry
|
||||
# ---------------------------------------------------------------------------
|
||||
STEAM_REGISTERED=0
|
||||
if [ "$DO_STEAM" -eq 1 ]; then
|
||||
info "Adding '$APP_NAME' to the Steam library"
|
||||
if register_steam_shortcut; then
|
||||
ok "Steam library entry ready"
|
||||
else
|
||||
warn "add $INSTALL_DIR/run.sh as a Non-Steam Game manually"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Summary
|
||||
# ---------------------------------------------------------------------------
|
||||
printf '\n%s%s installed%s\n' "$C_OK" "$APP_NAME" "$C_OFF"
|
||||
printf ' Version : %s\n' "$version_label"
|
||||
printf ' Install dir : %s\n' "$INSTALL_DIR"
|
||||
if [ "$DO_STEAM" -eq 1 ] && [ "$STEAM_REGISTERED" -eq 1 ]; then
|
||||
printf ' Steam entry : Non-Steam Game "%s"\n' "$APP_NAME"
|
||||
fi
|
||||
printf '\n Launch now : %s/run.sh\n' "$INSTALL_DIR"
|
||||
printf ' From Steam : Library > Non-Steam > %s\n' "$APP_NAME"
|
||||
printf ' Play a clip : %s/run.sh /path/to/clip.mp4\n' "$INSTALL_DIR"
|
||||
printf ' Logs : ~/pixelvr-run.log\n'
|
||||
printf ' Update : run this script again (settings and cache are kept)\n'
|
||||
if [ "$INSTALLED_SCRIPT" -eq 1 ]; then
|
||||
printf ' Remove : %s/install.sh --uninstall\n\n' "$INSTALL_DIR"
|
||||
else
|
||||
printf ' Remove : rerun this script with --uninstall\n\n'
|
||||
fi
|
||||
Regular → Executable
File mode changed.
Executable
+51
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env bash
|
||||
# Assemble the Steam Frame release tarball for a finished ARM64 build.
|
||||
#
|
||||
# Contents (extracted to ~/PixelVRPlayer):
|
||||
# PixelVRPlayer/pixelvr self-contained binary (static OpenXR loader)
|
||||
# PixelVRPlayer/run.sh launcher (logs to ~/pixelvr-run.log)
|
||||
# PixelVRPlayer/install.sh one-command installer / Steam registration
|
||||
# PixelVRPlayer/shaders/*.spv SPIR-V shaders
|
||||
# PixelVRPlayer/VERSION release tag (when given)
|
||||
#
|
||||
# ./tools/package-release.sh [build-dir] [out-tar] [version]
|
||||
#
|
||||
# Defaults: build -> pixelvr-linux-arm64.tar.gz
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
BUILD_DIR="${1:-build}"
|
||||
OUT_TAR="${2:-pixelvr-linux-arm64.tar.gz}"
|
||||
VERSION="${3:-}"
|
||||
|
||||
bin="${BUILD_DIR}/bin/pixelvr"
|
||||
if [[ ! -f "${bin}" ]]; then
|
||||
echo "error: ${bin} not found. Build first: ./tools/build.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "$(find "${BUILD_DIR}/shaders" -maxdepth 1 -name '*.spv' -print -quit)" ]]; then
|
||||
echo "error: no shaders in ${BUILD_DIR}/shaders" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
stage="$(mktemp -d)"
|
||||
trap 'rm -rf "${stage}"' EXIT
|
||||
|
||||
mkdir -p "${stage}/PixelVRPlayer/shaders"
|
||||
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 [[ -n "${VERSION}" ]]; then
|
||||
printf '%s\n' "${VERSION}" > "${stage}/PixelVRPlayer/VERSION"
|
||||
fi
|
||||
chmod +x "${stage}/PixelVRPlayer/pixelvr" \
|
||||
"${stage}/PixelVRPlayer/run.sh" \
|
||||
"${stage}/PixelVRPlayer/install.sh"
|
||||
|
||||
rm -f "${OUT_TAR}"
|
||||
tar -C "${stage}" -czf "${OUT_TAR}" PixelVRPlayer
|
||||
|
||||
echo "Packaged ${OUT_TAR} ($(du -h "${OUT_TAR}" | cut -f1)):"
|
||||
tar -tzf "${OUT_TAR}"
|
||||
Regular → Executable
File mode changed.
Executable
+19
@@ -0,0 +1,19 @@
|
||||
#!/bin/sh
|
||||
# Pixel VR Player launcher (Steam Frame). Logs to ~/pixelvr-run.log.
|
||||
#
|
||||
# This is the launcher shipped inside the release tarball as
|
||||
# PixelVRPlayer/run.sh. It runs the app directly on the SteamOS host.
|
||||
set -eu
|
||||
|
||||
here=$(cd "$(dirname "$0")" && pwd)
|
||||
|
||||
export PIXELVR_SHADER_DIR="$here/shaders"
|
||||
export PIXELVR_LOG=debug
|
||||
|
||||
# Optional: use a locally built patched FFmpeg for zero-copy dma-buf decode.
|
||||
if [ -d "$HOME/ffbuild/build/libavcodec" ]; then
|
||||
export LD_LIBRARY_PATH="$HOME/ffbuild/build/libavcodec:$HOME/ffbuild/build/libavutil:$HOME/ffbuild/build/libswresample${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
|
||||
export PIXELVR_DRM_PRIME=1
|
||||
fi
|
||||
|
||||
exec "$here/pixelvr" "$@" >>"$HOME/pixelvr-run.log" 2>&1
|
||||
Regular → Executable
File mode changed.
Reference in new issue
Block a user