mirror of
https://github.com/Pixelplanet/Pixel-VR-Player.git
synced 2026-10-21 23:01:44 +02:00
- Add Hidden Vault system: - Secure directory storage under ~/.pixelvr_vault with 0700 permissions. - Cheat-code combo unlocking triggered on full grip squeeze (A-B-B-A-X-Y default, customizable). - Headset removal / face detection gating and inactivity auto-lock. - Automatic download history clearing upon vault lock. - Add File Actions Submenu (Button Y): - In-browser file/folder submenu with Move to Vault, Delete, Move, and Rename. - Recursive folder move to vault with collision handling. - Folder deletion with confirmation dialog. - Enhance VR controllers and interactions: - Dynamic left and right Steam Frame controller render models. - Single laser pointer priority with hand swapping via full grip squeeze. - Fix video format detection: - Correct auto-detection and default projection for high-resolution VR180 SBS media (e.g. 7200x3600). - Visual sharpness & rendering stability: - Render swapchain at native 2160x2160 per eye. - SDF font rendering with RMS pixel footprint and stem darkening. - Razor-sharp procedural UI anti-aliasing and crisp border highlights without distortion. - Add unit test suite (tests/test_vault.cpp) and documentation specs.
1936 lines
95 KiB
C++
1936 lines
95 KiB
C++
#include "app/app.hpp"
|
|
#include "app/vault_manager.hpp"
|
|
|
|
#include "util/logging.hpp"
|
|
|
|
#include <cctype>
|
|
#include <chrono>
|
|
#include <cmath>
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <string>
|
|
#include <thread>
|
|
#include <utility>
|
|
#include <vector>
|
|
|
|
namespace fs = std::filesystem;
|
|
|
|
namespace pixelvr {
|
|
|
|
namespace {
|
|
|
|
// Quaternion/pose helpers (xyzw) for composing controller-model placement.
|
|
void quatRotate(const float q[4], const float v[3], float o[3]) {
|
|
const float ux = q[0], uy = q[1], uz = q[2], w = q[3];
|
|
const float c1x = uy * v[2] - uz * v[1] + w * v[0];
|
|
const float c1y = uz * v[0] - ux * v[2] + w * v[1];
|
|
const float c1z = ux * v[1] - uy * v[0] + w * v[2];
|
|
o[0] = v[0] + 2.0f * (uy * c1z - uz * c1y);
|
|
o[1] = v[1] + 2.0f * (uz * c1x - ux * c1z);
|
|
o[2] = v[2] + 2.0f * (ux * c1y - uy * c1x);
|
|
}
|
|
void quatMul(const float a[4], const float b[4], float o[4]) {
|
|
o[0] = a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1];
|
|
o[1] = a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0];
|
|
o[2] = a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3];
|
|
o[3] = a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2];
|
|
}
|
|
// out = A ∘ B composed as q = qA*qB, t = qA*tB + tA.
|
|
void composePose(const float pa[3], const float qa[4], const float pb[3],
|
|
const float qb[4], float po[3], float qo[4]) {
|
|
quatMul(qa, qb, qo);
|
|
float r[3];
|
|
quatRotate(qa, pb, r);
|
|
po[0] = r[0] + pa[0];
|
|
po[1] = r[1] + pa[1];
|
|
po[2] = r[2] + pa[2];
|
|
}
|
|
|
|
// Normalized lerp between quaternions (shortest path) for pose smoothing.
|
|
void quatNlerp(const float a[4], const float b[4], float t, float o[4]) {
|
|
const float dot = a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3];
|
|
const float s = dot < 0.0f ? -1.0f : 1.0f;
|
|
float len = 0.0f;
|
|
for (int i = 0; i < 4; ++i) {
|
|
o[i] = a[i] * (1.0f - t) + b[i] * s * t;
|
|
len += o[i] * o[i];
|
|
}
|
|
len = std::sqrt(len);
|
|
if (len > 1e-6f)
|
|
for (int i = 0; i < 4; ++i) o[i] /= len;
|
|
}
|
|
|
|
std::string to_lower(std::string s) {
|
|
for (char& c : s) {
|
|
c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
|
}
|
|
return s;
|
|
}
|
|
|
|
bool contains(const std::string& s, const char* token) {
|
|
return s.find(token) != std::string::npos;
|
|
}
|
|
|
|
// True if `needle` appears not immediately preceded or followed by a digit, so a marker like
|
|
// "360" matches but a resolution like "3600" (or "3600p") does not.
|
|
bool contains_num(const std::string& s, const char* needle) {
|
|
const std::string n = needle;
|
|
std::size_t pos = 0;
|
|
while ((pos = s.find(n, pos)) != std::string::npos) {
|
|
const bool prevDigit = (pos > 0 && s[pos - 1] >= '0' && s[pos - 1] <= '9');
|
|
const std::size_t after = pos + n.size();
|
|
const bool nextDigit = (after < s.size() && s[after] >= '0' && s[after] <= '9');
|
|
if (!prevDigit && !nextDigit) return true;
|
|
pos = (after > pos ? after : pos + 1);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Returns true if filename or path patterns indicate VR180 projection
|
|
bool is_vr180_text(const std::string& lower) {
|
|
if (contains(lower, "vr180") || contains(lower, "180vr") ||
|
|
contains(lower, "180x180") || contains(lower, "fisheye") ||
|
|
contains_num(lower, "180")) {
|
|
return true;
|
|
}
|
|
|
|
// VR resolution tokens commonly used for VR180 dual fisheye (vertical resolution or full frame)
|
|
const bool hasVrRes = contains_num(lower, "3600") || contains(lower, "3600p") ||
|
|
contains(lower, "-3600") || contains(lower, "_3600") || contains(lower, ".3600") ||
|
|
contains_num(lower, "2880") || contains(lower, "2880p") ||
|
|
contains(lower, "-2880") || contains(lower, "_2880") || contains(lower, ".2880") ||
|
|
contains_num(lower, "2700") || contains(lower, "2700p") ||
|
|
contains(lower, "-2700") || contains(lower, "_2700") || contains(lower, ".2700") ||
|
|
contains_num(lower, "3000") || contains(lower, "3000p") ||
|
|
contains_num(lower, "5400") || contains(lower, "5400p") ||
|
|
contains_num(lower, "5760") || contains(lower, "5760p") ||
|
|
contains(lower, "7200x3600") || contains(lower, "5760x2880") ||
|
|
contains(lower, "5400x2700") || contains(lower, "6000x3000");
|
|
|
|
// Well-known VR video studios / naming patterns
|
|
const bool hasVrStudio = contains(lower, "wvr") || contains(lower, "wankz") ||
|
|
contains(lower, "czechvr") || contains(lower, "vrhush") ||
|
|
contains(lower, "vrbangers") || contains(lower, "realjamvr") ||
|
|
contains(lower, "badoinkvr") || contains(lower, "badoink") ||
|
|
contains(lower, "milfvr") || contains(lower, "povr") ||
|
|
contains(lower, "vrcosplay") || contains(lower, "vrsmash") ||
|
|
contains(lower, "vrconk") || contains(lower, "slr_") ||
|
|
contains(lower, "slr-") || contains(lower, "_slr_") ||
|
|
contains(lower, "-slr-") || contains(lower, ".slr.") ||
|
|
contains(lower, "swallowvr") || contains(lower, "stripzvr") ||
|
|
contains(lower, "twistysvr") || contains(lower, "vrphoria") ||
|
|
contains(lower, "kinkvr") || contains(lower, "lustreality") ||
|
|
contains(lower, "joymiivr") || contains(lower, "naughtyamericavr");
|
|
|
|
const bool hasGenericVr = contains(lower, "_vr_") || contains(lower, "-vr-") ||
|
|
contains(lower, ".vr.") || contains(lower, " vr ");
|
|
|
|
// Check if filename explicitly says 360 (which overrides VR studio / VR generic marker)
|
|
const bool isExplicit360 = contains_num(lower, "360") || contains(lower, "equirect") ||
|
|
contains(lower, "vr360") || contains(lower, "360vr") ||
|
|
contains(lower, "360x180");
|
|
|
|
if (!isExplicit360 && (hasVrRes || hasVrStudio || (hasGenericVr && (hasVrRes || contains(lower, "3d") || contains(lower, "sbs"))))) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
bool is_vr360_text(const std::string& lower) {
|
|
if (contains(lower, "vr180") || contains(lower, "180vr") ||
|
|
contains(lower, "180x180") || contains(lower, "fisheye") ||
|
|
contains_num(lower, "180")) {
|
|
return false;
|
|
}
|
|
if (contains_num(lower, "360") || contains(lower, "equirect") ||
|
|
contains(lower, "vr360") || contains(lower, "360vr") ||
|
|
contains(lower, "360x180") || contains(lower, "sphere")) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
bool is_vr180_media(const std::string& pathOrName, int w, int h) {
|
|
const std::string lower = to_lower(pathOrName);
|
|
if (is_vr180_text(lower)) return true;
|
|
|
|
// Check dimensions if probed (dual fisheye SBS aspect ~2.0:1)
|
|
if (w > 0 && h > 0) {
|
|
const float ar = static_cast<float>(w) / static_cast<float>(h);
|
|
const bool is360 = is_vr360_text(lower);
|
|
if (!is360) {
|
|
// Exact signature VR180 dual fisheye resolutions
|
|
if ((w == 7200 && h == 3600) || (w == 5760 && h == 2880) ||
|
|
(w == 5400 && h == 2700) || (w == 6000 && h == 3000) ||
|
|
(w == 4800 && h == 2400) || (w == 7680 && h == 3840 && (contains(lower, "vr") || contains(lower, "180")))) {
|
|
return true;
|
|
}
|
|
// General 2:1 dual fisheye with vertical resolution >= 2600 (not standard flat)
|
|
if (h >= 2600 && ar >= 1.90f && ar <= 2.10f) {
|
|
return true;
|
|
}
|
|
// If name has any vr marker or 3d and ar is ~2.0
|
|
if ((contains(lower, "vr") || contains(lower, "3d")) && ar >= 1.85f && ar <= 2.15f) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
bool is_vr360_media(const std::string& pathOrName, int w, int h) {
|
|
const std::string lower = to_lower(pathOrName);
|
|
if (is_vr180_media(pathOrName, w, h)) return false;
|
|
return is_vr360_text(lower);
|
|
}
|
|
|
|
// Infers projection and stereo layout from common filename conventions, e.g.
|
|
// "clip_360_tb.mp4", "movie_180_sbs.mkv", "trailer_vr180.mp4". `stereoFromName`
|
|
// reports whether the stereo layout was stated explicitly (so the caller can
|
|
// refine it from the frame aspect ratio otherwise).
|
|
void detect_format(const std::string& path, VulkanRenderer::ProjectionMode& proj,
|
|
VulkanRenderer::StereoMode& stereo, bool& stereoFromName) {
|
|
const std::string p = to_lower(path);
|
|
|
|
if (is_vr180_text(p)) {
|
|
proj = VulkanRenderer::ProjectionMode::Equirect180;
|
|
} else if (is_vr360_text(p)) {
|
|
proj = VulkanRenderer::ProjectionMode::Equirect360;
|
|
} else {
|
|
proj = VulkanRenderer::ProjectionMode::Flat;
|
|
}
|
|
|
|
stereoFromName = true;
|
|
if (contains(p, "_tb") || contains(p, "-tb") || contains(p, "_ou") ||
|
|
contains(p, "overunder") || contains(p, "over-under") ||
|
|
contains(p, "htab") || contains(p, "h-tab")) {
|
|
stereo = VulkanRenderer::StereoMode::TopBottom;
|
|
} else if (contains(p, "sbs") || contains(p, "_lr") ||
|
|
contains(p, "sidebyside") || contains(p, "side-by-side") ||
|
|
contains(p, "hsbs") || contains(p, "half-sbs")) {
|
|
stereo = VulkanRenderer::StereoMode::SideBySide; // incl. HSBS / half-SBS
|
|
} else if (contains(p, "3d")) {
|
|
// Bare "3D" with no explicit layout: half-SBS dominates movie rips.
|
|
stereo = VulkanRenderer::StereoMode::SideBySide;
|
|
} else if (proj == VulkanRenderer::ProjectionMode::Equirect180) {
|
|
// VR180 almost universally defaults to SideBySide 3D
|
|
stereo = VulkanRenderer::StereoMode::SideBySide;
|
|
stereoFromName = false; // allow refining from aspect ratio once frame is opened
|
|
} else {
|
|
stereo = VulkanRenderer::StereoMode::Mono;
|
|
stereoFromName = false; // no explicit layout; refine from aspect ratio
|
|
}
|
|
}
|
|
|
|
} // namespace
|
|
|
|
bool App::init(const std::string& mediaPath) {
|
|
if (!xr_.createInstanceAndSystem("Pixel VR Player")) {
|
|
return false;
|
|
}
|
|
if (!renderer_.createDevice(xr_)) {
|
|
return false;
|
|
}
|
|
if (!xr_.createSession(renderer_.graphicsBinding())) {
|
|
return false;
|
|
}
|
|
if (!xr_.createReferenceSpace()) {
|
|
return false;
|
|
}
|
|
if (!xr_.createActions()) {
|
|
PIXELVR_LOG_WARN("Controller input unavailable; continuing without it");
|
|
}
|
|
renderModels_.init(xr_.instance(), xr_.session(), xr_.renderModelEnabled());
|
|
|
|
const std::vector<int64_t> formats = xr_.enumerateSwapchainFormats();
|
|
if (formats.empty()) {
|
|
PIXELVR_LOG_ERROR("Runtime reported no swapchain formats");
|
|
return false;
|
|
}
|
|
const int64_t colorFormat = renderer_.chooseColorFormat(formats);
|
|
if (!xr_.createSwapchains(colorFormat)) {
|
|
return false;
|
|
}
|
|
if (!renderer_.createRenderResources(xr_.swapchains())) {
|
|
return false;
|
|
}
|
|
|
|
#ifdef PIXELVR_HAVE_MEDIA
|
|
if (!mediaPath.empty()) {
|
|
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;
|
|
#endif
|
|
// No clip requested: open the file browser instead of a test video.
|
|
if (mediaPath.empty()) {
|
|
#ifdef PIXELVR_HAVE_FREETYPE
|
|
browser_.open();
|
|
#endif
|
|
}
|
|
|
|
PIXELVR_LOG_INFO("Initialization complete");
|
|
return true;
|
|
}
|
|
|
|
#ifdef PIXELVR_HAVE_MEDIA
|
|
void App::loadMedia(const std::string& path) {
|
|
const bool sameFile = (path == currentMediaPath_);
|
|
currentMediaPath_ = path;
|
|
VulkanRenderer::ProjectionMode proj = VulkanRenderer::ProjectionMode::Flat;
|
|
VulkanRenderer::StereoMode stereo = VulkanRenderer::StereoMode::Mono;
|
|
bool stereoFromName = false;
|
|
detect_format(path, proj, stereo, stereoFromName);
|
|
|
|
renderer_.releaseVideoFrame(); // free the previous clip's dma-buf before reopening
|
|
renderer_.setSphereZoom(1.0f); // reset the 360/180 viewpoint per clip
|
|
renderer_.setSpherePitch(0.0f);
|
|
media_.setPreferredAudioLanguage(settings_.audioLanguage());
|
|
if (!sameFile) { // a new file starts from automatic track selection
|
|
media_.setPreferredVideoStream(-1);
|
|
media_.setPreferredAudioStream(-1);
|
|
}
|
|
mediaOpen_ = media_.open(path);
|
|
if (!mediaOpen_) {
|
|
// Clear stale video from the previous clip so the renderer shows black
|
|
// instead of the last decoded frame of the old file.
|
|
renderer_.clearVideo();
|
|
PIXELVR_LOG_WARN("Failed to open %s", path.c_str());
|
|
return;
|
|
}
|
|
|
|
// Refine the stereo layout from the frame aspect ratio when the filename did
|
|
// not state it: once the projection is a VR type the aspect is unambiguous
|
|
// (360 mono 2:1, SBS 4:1, OU 1:1; 180 mono 1:1, SBS 2:1, OU 1:2).
|
|
const int w = media_.width();
|
|
const int h = media_.height();
|
|
|
|
// If filename did not indicate VR, check if probed dimensions reveal VR180
|
|
if (proj == VulkanRenderer::ProjectionMode::Flat && h > 0) {
|
|
if (is_vr180_media(path, w, h)) {
|
|
proj = VulkanRenderer::ProjectionMode::Equirect180;
|
|
if (!stereoFromName) {
|
|
stereo = VulkanRenderer::StereoMode::SideBySide;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!stereoFromName && h > 0) {
|
|
const float ar = static_cast<float>(w) / static_cast<float>(h);
|
|
if (proj == VulkanRenderer::ProjectionMode::Equirect360) {
|
|
stereo = ar >= 3.0f ? VulkanRenderer::StereoMode::SideBySide
|
|
: ar <= 1.3f ? VulkanRenderer::StereoMode::TopBottom
|
|
: VulkanRenderer::StereoMode::Mono;
|
|
} else if (proj == VulkanRenderer::ProjectionMode::Equirect180) {
|
|
stereo = ar >= 1.6f ? VulkanRenderer::StereoMode::SideBySide
|
|
: ar <= 0.7f ? VulkanRenderer::StereoMode::TopBottom
|
|
: VulkanRenderer::StereoMode::Mono;
|
|
} else { // Flat: full-frame SBS roughly doubles the width.
|
|
stereo = ar >= 2.9f ? VulkanRenderer::StereoMode::SideBySide
|
|
: VulkanRenderer::StereoMode::Mono;
|
|
}
|
|
}
|
|
|
|
renderer_.setProjectionMode(proj);
|
|
renderer_.setStereoMode(stereo);
|
|
settings_.setProjectionMode(static_cast<int>(proj));
|
|
settings_.setStereoMode(static_cast<int>(stereo));
|
|
// Publish selectable tracks for the settings Audio/Video section.
|
|
std::vector<std::pair<int, std::string>> vlist, alist;
|
|
for (const auto& s : media_.videoStreams()) vlist.emplace_back(s.index, s.label);
|
|
for (const auto& s : media_.audioStreams()) alist.emplace_back(s.index, s.label);
|
|
settings_.setStreams(vlist, alist, media_.currentVideoStream(),
|
|
media_.currentAudioStream());
|
|
static const char* const kProj[] = {"flat", "360", "180"};
|
|
static const char* const kStereo[] = {"mono", "SBS", "TB"};
|
|
PIXELVR_LOG_INFO("Loaded %s (%dx%d, %s/%s)", path.c_str(), w, h,
|
|
kProj[static_cast<int>(proj)],
|
|
kStereo[static_cast<int>(stereo)]);
|
|
}
|
|
|
|
void App::switchTracks(bool byLanguage) {
|
|
if (currentMediaPath_.empty() || !mediaOpen_) return;
|
|
const double pos = media_.position();
|
|
media_.setPreferredAudioLanguage(settings_.audioLanguage());
|
|
media_.setPreferredVideoStream(settings_.selectedVideoStream());
|
|
media_.setPreferredAudioStream(byLanguage ? -1
|
|
: settings_.selectedAudioStream());
|
|
loadMedia(currentMediaPath_); // same file: keeps the preferences set above
|
|
if (pos > 0.5 && media_.duration() > 0.0) media_.seek(pos);
|
|
}
|
|
|
|
void App::updateStats() {
|
|
using clock = std::chrono::steady_clock;
|
|
const auto now = clock::now();
|
|
++statsFrameAccum_;
|
|
if (lastStatsUpdate_.time_since_epoch().count() == 0) {
|
|
lastStatsUpdate_ = now;
|
|
statsLastDecoded_ = media_.decodedFrames();
|
|
return;
|
|
}
|
|
const double elapsed =
|
|
std::chrono::duration<double>(now - lastStatsUpdate_).count();
|
|
if (elapsed < 0.5) {
|
|
return; // refresh the readout ~twice a second
|
|
}
|
|
|
|
const double fps = statsFrameAccum_ / elapsed;
|
|
const uint64_t dec = media_.decodedFrames();
|
|
if (dec >= statsLastDecoded_) {
|
|
const double decFps =
|
|
static_cast<double>(dec - statsLastDecoded_) / elapsed;
|
|
decFpsEma_ = (decFpsEma_ > 0.0) ? decFpsEma_ * 0.6 + decFps * 0.4 : decFps;
|
|
} else {
|
|
decFpsEma_ = 0.0; // decodedFrames reset on a new clip: restart the average
|
|
}
|
|
statsFrameAccum_ = 0;
|
|
statsLastDecoded_ = dec;
|
|
lastStatsUpdate_ = now;
|
|
|
|
// Aggregate CPU load from /proc/stat deltas.
|
|
float cpuPct = -1.0f;
|
|
{
|
|
std::ifstream f("/proc/stat");
|
|
std::string cpu;
|
|
unsigned long long u = 0, n = 0, s = 0, idle = 0, io = 0, irq = 0,
|
|
sirq = 0, steal = 0;
|
|
if (f >> cpu >> u >> n >> s >> idle >> io >> irq >> sirq >> steal) {
|
|
const unsigned long long idleAll = idle + io;
|
|
const unsigned long long total =
|
|
u + n + s + idle + io + irq + sirq + steal;
|
|
if (cpuPrevTotal_ != 0 && total > cpuPrevTotal_) {
|
|
const unsigned long long dtot = total - cpuPrevTotal_;
|
|
const unsigned long long didle = idleAll - cpuPrevIdle_;
|
|
cpuPct = 100.0f * static_cast<float>(dtot - didle) /
|
|
static_cast<float>(dtot);
|
|
}
|
|
cpuPrevTotal_ = total;
|
|
cpuPrevIdle_ = idleAll;
|
|
}
|
|
}
|
|
|
|
// RAM usage from /proc/meminfo.
|
|
long memTotalKb = 0, memAvailKb = 0;
|
|
{
|
|
std::ifstream f("/proc/meminfo");
|
|
std::string key, unit;
|
|
long val = 0;
|
|
while (f >> key >> val >> unit) {
|
|
if (key == "MemTotal:") memTotalKb = val;
|
|
else if (key == "MemAvailable:") { memAvailKb = val; break; }
|
|
}
|
|
}
|
|
|
|
// Adreno GPU: the Frame runs the Turnip/msm driver (no kgsl sysfs), so report
|
|
// the GPU clock from devfreq (the governor scales it with load; pegged at max
|
|
// means GPU-bound).
|
|
long gpuCurMhz = 0, gpuMaxMhz = 0;
|
|
{
|
|
std::ifstream cf("/sys/class/devfreq/3d00000.gpu/cur_freq");
|
|
std::ifstream mf("/sys/class/devfreq/3d00000.gpu/max_freq");
|
|
long cur = 0, mx = 0;
|
|
if (cf >> cur) gpuCurMhz = cur / 1000000;
|
|
if (mf >> mx) gpuMaxMhz = mx / 1000000;
|
|
}
|
|
|
|
char buf[160];
|
|
statsLines_.clear();
|
|
std::snprintf(buf, sizeof(buf), "FPS %.1f (%.1f ms)", fps,
|
|
fps > 0.0 ? 1000.0 / fps : 0.0);
|
|
statsLines_.emplace_back(buf);
|
|
if (mediaOpen_) {
|
|
std::snprintf(buf, sizeof(buf), "Video %dx%d %s", media_.width(),
|
|
media_.height(), media_.codecName().c_str());
|
|
statsLines_.emplace_back(buf);
|
|
std::snprintf(buf, sizeof(buf), "Decode %.1f / %.2f fps", decFpsEma_,
|
|
media_.frameRate());
|
|
statsLines_.emplace_back(buf);
|
|
}
|
|
if (cpuPct >= 0.0f) {
|
|
std::snprintf(buf, sizeof(buf), "CPU %.0f%%", cpuPct);
|
|
statsLines_.emplace_back(buf);
|
|
}
|
|
if (gpuMaxMhz > 0) {
|
|
std::snprintf(buf, sizeof(buf), "GPU %ld/%ld MHz", gpuCurMhz, gpuMaxMhz);
|
|
} else {
|
|
std::snprintf(buf, sizeof(buf), "GPU n/a");
|
|
}
|
|
statsLines_.emplace_back(buf);
|
|
if (memTotalKb > 0) {
|
|
std::snprintf(buf, sizeof(buf), "RAM %ld / %ld MB",
|
|
(memTotalKb - memAvailKb) / 1024, memTotalKb / 1024);
|
|
statsLines_.emplace_back(buf);
|
|
}
|
|
}
|
|
#endif
|
|
|
|
int App::navStep(float axisY, std::chrono::steady_clock::time_point now) {
|
|
using namespace std::chrono_literals;
|
|
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 + 350ms;
|
|
return dir;
|
|
}
|
|
if (now >= navNextRepeat_) { // held down: auto-repeat
|
|
navNextRepeat_ = now + 120ms;
|
|
return dir;
|
|
}
|
|
return 0;
|
|
}
|
|
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;
|
|
}
|
|
|
|
void App::performVaultLock() {
|
|
VaultManager::instance().lock();
|
|
renderer_.setVaultUnlocked(false);
|
|
actionMenu_.visible = false;
|
|
renderer_.setActionMenu({});
|
|
|
|
#ifdef PIXELVR_HAVE_MEDIA
|
|
if (mediaOpen_ && VaultManager::instance().isVaultPath(currentMediaPath_)) {
|
|
media_.close();
|
|
mediaOpen_ = false;
|
|
renderer_.clearVideo();
|
|
}
|
|
#endif
|
|
|
|
#ifdef PIXELVR_HAVE_FREETYPE
|
|
if (browser_.inVault()) {
|
|
browser_.openHome();
|
|
}
|
|
browser_.clearFinished();
|
|
downloadToast_ = {};
|
|
downloadToastText_.clear();
|
|
renderer_.clearCardThumbnails();
|
|
thumbnailCache_.clearVaultThumbs(VaultManager::instance().vaultDir());
|
|
#endif
|
|
|
|
if (settings_.section() == 4) {
|
|
settings_.setSection(0);
|
|
}
|
|
settings_.refresh();
|
|
|
|
PIXELVR_LOG_INFO("Vault locked and sanitized");
|
|
}
|
|
|
|
void App::openActionMenu() {
|
|
#ifdef PIXELVR_HAVE_FREETYPE
|
|
const Browser::Entry* fe = browser_.focused();
|
|
if (fe == nullptr || fe->isBack || browser_.onNetwork()) return;
|
|
|
|
actionMenu_.visible = true;
|
|
actionMenu_.page = 0;
|
|
actionMenu_.selectedOption = 0;
|
|
actionMenu_.title = fe->isDir ? "Folder Actions" : "File Actions";
|
|
actionMenu_.subtitle = fe->label;
|
|
actionMenu_.options.clear();
|
|
|
|
if (VaultManager::instance().isUnlocked() && !browser_.inVault()) {
|
|
actionMenu_.options.push_back("Move to Vault");
|
|
}
|
|
actionMenu_.options.push_back("Move To...");
|
|
actionMenu_.options.push_back("Rename");
|
|
actionMenu_.options.push_back("Delete");
|
|
actionMenu_.options.push_back("Cancel");
|
|
actionMenu_.renameBuffer.clear();
|
|
#endif
|
|
}
|
|
|
|
int App::run() {
|
|
using namespace std::chrono_literals;
|
|
|
|
while (!exitRequested_) {
|
|
if (!xr_.pollEvents(exitRequested_) || exitRequested_) {
|
|
break;
|
|
}
|
|
|
|
if (xr_.sessionRunning()) {
|
|
// Compute frame delta-time for continuous seek (F4).
|
|
const auto now = std::chrono::steady_clock::now();
|
|
float dt = 0.0f;
|
|
if (lastFrameTime_.time_since_epoch().count() > 0) {
|
|
dt = std::chrono::duration<float>(now - lastFrameTime_).count();
|
|
dt = std::clamp(dt, 0.0f, 0.1f); // cap to avoid huge jumps
|
|
}
|
|
lastFrameTime_ = now;
|
|
|
|
#ifdef PIXELVR_HAVE_MEDIA
|
|
// Release held dma-buf frames when the decoder is about to seek so it
|
|
// can reclaim its V4L2 capture buffers (otherwise it wedges).
|
|
if (mediaOpen_ && media_.needsFrameRelease()) {
|
|
renderer_.releaseVideoFrame();
|
|
media_.confirmFrameRelease();
|
|
}
|
|
if (mediaOpen_ && media_.drmActive()) {
|
|
// Zero-copy dma-buf path: import the decoder's frame directly.
|
|
if (media_.latestDrmFrame(drmFrame_)) {
|
|
renderer_.updateVideoDmabuf(drmFrame_);
|
|
}
|
|
} else if (mediaOpen_ && media_.latestFrame(frame_)) {
|
|
renderer_.updateVideoTexture(frame_.width, frame_.height,
|
|
frame_.y.data(), frame_.uv.data(),
|
|
frame_.colorspace, frame_.fullRange);
|
|
}
|
|
#endif
|
|
xr_.renderFrame([this](uint32_t viewIndex, uint32_t imageIndex,
|
|
const XrView& view, const ViewSwapchain& swapchain) {
|
|
renderer_.renderView(viewIndex, imageIndex, view, swapchain);
|
|
});
|
|
|
|
// Apply controller input that renderFrame synced this frame.
|
|
const auto nowT = std::chrono::steady_clock::now();
|
|
XrInputState in = xr_.input();
|
|
|
|
// Custom combo recording listener (active while recording new 6-button combo in Vault & Privacy)
|
|
if (VaultManager::instance().isRecordingCombo()) {
|
|
for (int b : {XrInputState::BtnA, XrInputState::BtnB, XrInputState::BtnX, XrInputState::BtnY}) {
|
|
if (in.buttonDown[b]) {
|
|
const bool finished = VaultManager::instance().feedRecordButton(b);
|
|
if (finished) {
|
|
xr_.triggerHaptic(0, 0.050f, 0.0f, 0.8f);
|
|
xr_.triggerHaptic(1, 0.050f, 0.0f, 0.8f);
|
|
} else {
|
|
xr_.triggerHaptic(0, 0.020f, 0.0f, 0.4f);
|
|
xr_.triggerHaptic(1, 0.020f, 0.0f, 0.4f);
|
|
}
|
|
settings_.refresh();
|
|
break;
|
|
}
|
|
}
|
|
in.togglePlay = false;
|
|
in.browserToggle = false;
|
|
in.recenter = false;
|
|
} else if (settings_.isListening()) {
|
|
for (int b = 0; b < XrInputState::BtnCount; ++b) {
|
|
if (in.buttonDown[b]) {
|
|
settings_.assignListening(b);
|
|
break;
|
|
}
|
|
}
|
|
in.togglePlay = false;
|
|
in.browserToggle = false;
|
|
in.recenter = false;
|
|
} else {
|
|
in.togglePlay = in.buttonDown[settings_.bindPlay()];
|
|
in.browserToggle = in.buttonDown[settings_.bindMenu()];
|
|
in.recenter = in.buttonDown[settings_.bindRecenter()];
|
|
}
|
|
|
|
// Discrete grip full-press cheat code detection:
|
|
// Holding full press of either grip button (bottomed out) and entering the button sequence.
|
|
const bool gripFullHeld = in.gripFullPressHeld[0] || in.gripFullPressHeld[1];
|
|
static bool sPrevGripFullHeld = false;
|
|
if (gripFullHeld != sPrevGripFullHeld) {
|
|
sPrevGripFullHeld = gripFullHeld;
|
|
PIXELVR_LOG_INFO("Grip full press state changed: %s (L val: %.2f, R val: %.2f)",
|
|
gripFullHeld ? "HELD" : "RELEASED",
|
|
in.gripValue[0], in.gripValue[1]);
|
|
}
|
|
|
|
if (gripFullHeld && !settings_.isListening() && !VaultManager::instance().isRecordingCombo()) {
|
|
const int h = in.gripFullPressHeld[1] ? 1 : 0;
|
|
for (int b : {XrInputState::BtnA, XrInputState::BtnB, XrInputState::BtnX, XrInputState::BtnY}) {
|
|
if (in.buttonDown[b]) {
|
|
PIXELVR_LOG_INFO("Combo input button press: %s (hand %s)",
|
|
(b == XrInputState::BtnA) ? "A" :
|
|
(b == XrInputState::BtnB) ? "B" :
|
|
(b == XrInputState::BtnX) ? "X" : "Y",
|
|
h == 0 ? "left" : "right");
|
|
auto res = VaultManager::instance().feedInput(b, true, nowT);
|
|
if (res == VaultManager::ComboResult::StepMatched) {
|
|
PIXELVR_LOG_INFO("Vault combo step matched");
|
|
xr_.triggerHaptic(h, 0.015f, 0.0f, 0.4f);
|
|
} else if (res == VaultManager::ComboResult::Unlocked) {
|
|
PIXELVR_LOG_INFO("Vault combo SUCCESS: UNLOCKED");
|
|
xr_.triggerHaptic(h, 0.060f, 0.0f, 0.9f);
|
|
renderer_.setVaultUnlocked(true);
|
|
if (settings_.isOpen()) {
|
|
settings_.refresh();
|
|
} else {
|
|
downloadsWindow_ = false;
|
|
settings_.close();
|
|
browser_.openVault();
|
|
}
|
|
} else if (res == VaultManager::ComboResult::Locked) {
|
|
PIXELVR_LOG_INFO("Vault combo: RELOCKED");
|
|
xr_.triggerHaptic(h, 0.060f, 0.0f, 0.7f);
|
|
performVaultLock();
|
|
} else {
|
|
PIXELVR_LOG_INFO("Vault combo reset / mismatch");
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
// Suppress normal button functions while grip is bottomed out
|
|
in.togglePlay = false;
|
|
in.browserToggle = false;
|
|
in.recenter = false;
|
|
}
|
|
|
|
// X is the dedicated download button: it downloads the highlighted SMB entry.
|
|
// Y (or configured move button) is the dedicated File Actions Submenu button.
|
|
const bool downloadPressed =
|
|
!settings_.isListening() && !settings_.isOpen() && !gripFullHeld &&
|
|
!VaultManager::instance().isRecordingCombo() &&
|
|
in.buttonDown[XrInputState::BtnX];
|
|
|
|
const int configuredMoveBtn = VaultManager::instance().moveButton();
|
|
const bool actionBtnPressed =
|
|
!settings_.isListening() && !settings_.isOpen() && !gripFullHeld &&
|
|
!VaultManager::instance().isRecordingCombo() &&
|
|
(in.buttonDown[XrInputState::BtnY] ||
|
|
(configuredMoveBtn >= 0 && configuredMoveBtn < XrInputState::BtnCount &&
|
|
in.buttonDown[configuredMoveBtn]));
|
|
|
|
// Grip full-press click: activate laser pointer on this hand.
|
|
// Exactly one controller has an active laser pointer at any time.
|
|
if (!settings_.isListening() && !VaultManager::instance().isRecordingCombo()) {
|
|
for (int h = 0; h < 2; ++h) {
|
|
if (in.gripFullPressDown[h]) {
|
|
if (!renderer_.laserOn(h)) {
|
|
renderer_.setLaserOn(h, true);
|
|
xr_.triggerHaptic(h, 0.035f, 0.0f, 0.6f);
|
|
PIXELVR_LOG_INFO("Laser switched to %s controller",
|
|
h == 0 ? "left" : "right");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
bool interacted = false;
|
|
bool browserOpen = false;
|
|
bool settingsOpen = false;
|
|
|
|
// Fetch the runtime-provided controller models once a controller is
|
|
// tracked (for left 0 and right 1 hands).
|
|
if (in.controllers[0].active || in.controllers[1].active) {
|
|
renderModels_.poll();
|
|
for (int h = 0; h < 2; ++h) {
|
|
if (!controllerModelLoaded_[h] && renderModels_.assetReady(h)) {
|
|
controllerModelLoaded_[h] =
|
|
renderer_.loadControllerModel(h, renderModels_.gltf(h));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Drive each model's per-node animation (button presses, trigger pull,
|
|
// thumbstick tilt) from the runtime's render-model state.
|
|
for (int h = 0; h < 2; ++h) {
|
|
if (controllerModelLoaded_[h]) {
|
|
std::vector<RenderModelSystem::NodeState> ns;
|
|
if (renderModels_.nodeStates(h, xr_.displayTime(), ns)) {
|
|
const auto& names = renderModels_.nodeNames(h);
|
|
std::vector<VulkanRenderer::NodeStateVis> vis;
|
|
vis.reserve(ns.size());
|
|
for (std::size_t i = 0; i < ns.size(); ++i) {
|
|
VulkanRenderer::NodeStateVis v;
|
|
v.name = (i < names.size()) ? names[i] : std::string();
|
|
for (int k = 0; k < 3; ++k) v.pos[k] = ns[i].pos[k];
|
|
for (int k = 0; k < 4; ++k) v.quat[k] = ns[i].quat[k];
|
|
v.visible = ns[i].visible;
|
|
vis.push_back(v);
|
|
}
|
|
renderer_.setControllerNodeStates(h, vis);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
// Controller activity: moving a controller past a small threshold (or
|
|
// any button/stick input) keeps the laser, models and controls shown;
|
|
// after a few seconds of stillness they fade away.
|
|
bool activity = false;
|
|
for (int h = 0; h < 2; ++h) {
|
|
const auto& c = in.controllers[h];
|
|
if (!c.active) {
|
|
activityAnchorValid_[h] = false;
|
|
continue;
|
|
}
|
|
if (!activityAnchorValid_[h]) {
|
|
activityAnchorValid_[h] = true;
|
|
for (int k = 0; k < 3; ++k) activityAnchorPos_[h][k] = c.position[k];
|
|
for (int k = 0; k < 4; ++k) activityAnchorOrient_[h][k] = c.orientation[k];
|
|
continue;
|
|
}
|
|
float dp = 0.0f;
|
|
for (int k = 0; k < 3; ++k) {
|
|
const float d = c.position[k] - activityAnchorPos_[h][k];
|
|
dp += d * d;
|
|
}
|
|
float od = 0.0f;
|
|
for (int k = 0; k < 4; ++k) od += c.orientation[k] * activityAnchorOrient_[h][k];
|
|
if (std::sqrt(dp) > 0.02f || std::fabs(od) < 0.995f) {
|
|
activity = true;
|
|
for (int k = 0; k < 3; ++k) activityAnchorPos_[h][k] = c.position[k];
|
|
for (int k = 0; k < 4; ++k) activityAnchorOrient_[h][k] = c.orientation[k];
|
|
}
|
|
}
|
|
if (in.togglePlay || in.browserToggle || in.select || in.selectHeld ||
|
|
in.recenter || std::fabs(in.thumbstickX) > 0.15f ||
|
|
std::fabs(in.thumbstickY) > 0.15f ||
|
|
std::fabs(in.thumbstickRX) > 0.15f) {
|
|
activity = true;
|
|
}
|
|
if (activity) {
|
|
lastControllerActivity_ = nowT;
|
|
VaultManager::instance().recordActivity(nowT);
|
|
}
|
|
if (VaultManager::instance().tick(nowT, in.headValid && xr_.sessionFocused())) {
|
|
performVaultLock();
|
|
}
|
|
const bool controllersEngaged =
|
|
(nowT - lastControllerActivity_) < std::chrono::milliseconds(3000);
|
|
|
|
#ifdef PIXELVR_HAVE_FREETYPE
|
|
if (in.browserToggle) {
|
|
if (actionMenu_.visible) {
|
|
actionMenu_.visible = false;
|
|
} else if (downloadsWindow_) {
|
|
downloadsWindow_ = false;
|
|
} else if (settings_.isOpen()) {
|
|
settings_.close();
|
|
} else if (browser_.isOpen()) {
|
|
browser_.setOpen(false);
|
|
} else {
|
|
browser_.open();
|
|
}
|
|
}
|
|
// Downloads run on their own thread and keep going while the browser
|
|
// is closed; poll every frame so completion is handled regardless of
|
|
// whether the panel is visible.
|
|
browser_.setDownloadDir(settings_.downloadDir());
|
|
{
|
|
std::string donePath;
|
|
if (browser_.poll(donePath) == Browser::Result::Play &&
|
|
!donePath.empty()) {
|
|
#ifdef PIXELVR_HAVE_MEDIA
|
|
loadMedia(donePath);
|
|
#endif
|
|
browser_.setOpen(false);
|
|
}
|
|
}
|
|
if (browser_.downloadActive()) {
|
|
downloadToastText_ = browser_.downloadText();
|
|
downloadToast_ = nowT + std::chrono::seconds(10);
|
|
// Show the downloads window on the rising edge only if the user
|
|
// is not already browsing; do not close the browser.
|
|
if (!dlWasActive_) {
|
|
dlWasActive_ = true;
|
|
if (!browser_.isOpen()) downloadsWindow_ = true;
|
|
}
|
|
} else {
|
|
dlWasActive_ = false;
|
|
}
|
|
const bool dlRecent = browser_.downloadActive() || browser_.hasQueuedDownloads() || nowT < downloadToast_;
|
|
bool dlFailed = false;
|
|
for (const auto& j : browser_.downloadJobs()) {
|
|
if (j.state == 2) dlFailed = true;
|
|
}
|
|
// No head-locked download text (it followed the view); progress lives
|
|
// in the Downloads window and the control-bar badge.
|
|
const bool dlAnyActive = browser_.downloadActive() || browser_.hasQueuedDownloads();
|
|
renderer_.setDownloadStatus(false, "");
|
|
renderer_.setDownloadsBadge(dlAnyActive,
|
|
!dlAnyActive &&
|
|
browser_.hasDownloads() && dlRecent,
|
|
!dlAnyActive && dlFailed);
|
|
// Downloads window: job list plus action buttons at the bottom.
|
|
std::vector<std::string> drows;
|
|
std::vector<std::string> dbtns;
|
|
if (browser_.downloadActive() || browser_.hasQueuedDownloads()) {
|
|
dbtns.push_back(browser_.downloadPaused() ? "Resume All" : "Pause All");
|
|
dbtns.push_back("Cancel Active");
|
|
}
|
|
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 || j.state == 4) ++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;
|
|
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 if (j.state == 4) {
|
|
std::snprintf(buf, sizeof(buf), "%s queued", nm.c_str());
|
|
di.detail = "Queued in download queue (click to cancel)";
|
|
} else {
|
|
std::snprintf(buf, sizeof(buf), "%s failed", nm.c_str());
|
|
di.detail = "Transfer Failed";
|
|
}
|
|
drows.push_back(buf);
|
|
djList.push_back(std::move(di));
|
|
}
|
|
}
|
|
renderer_.setActiveDownloadCount(activeDlCount);
|
|
const bool dlWin = downloadsWindow_ && !settings_.isOpen() &&
|
|
pendingDl_.empty();
|
|
renderer_.setDownloadsModal(false);
|
|
renderer_.setDownloads(
|
|
dlWin, "Downloads", drows,
|
|
browser_.downloadActive()
|
|
? browser_.downloadText()
|
|
: "Trigger: Play Completed | B: Close Hub",
|
|
dbtns, djList);
|
|
|
|
// Folder-download confirmation: a modal with the size and two buttons.
|
|
if (!pendingDl_.empty()) {
|
|
bool confirmNow = false;
|
|
if (pendingDlFresh_) {
|
|
pendingDlFresh_ = false; // ignore the press that opened it
|
|
} else if (in.togglePlay) {
|
|
confirmNow = true;
|
|
} else if (in.browserToggle) {
|
|
pendingDl_.clear();
|
|
}
|
|
if (!pendingDl_.empty()) {
|
|
char sz[96];
|
|
std::snprintf(sz, sizeof(sz), "Size: %.2f GB%s",
|
|
browser_.measuredSize() / 1073741824.0,
|
|
browser_.measuring() ? " (scanning...)" : "");
|
|
std::vector<std::string> crows{"Download this folder?",
|
|
pendingDlName_, sz};
|
|
std::vector<std::string> cbtns{"Download", "Cancel"};
|
|
renderer_.setDownloadsModal(true);
|
|
renderer_.setDownloads(true, "Confirm Download", crows,
|
|
"A: Download B: Cancel", cbtns);
|
|
const int cbi = renderer_.downloadsButtonHovered();
|
|
if (in.select) {
|
|
PIXELVR_LOG_INFO("confirm click: btn=%d hovered=%d", cbi,
|
|
renderer_.downloadsHovered());
|
|
}
|
|
if (in.select && cbi == 0) {
|
|
confirmNow = true;
|
|
} else if (in.select && cbi == 1) {
|
|
pendingDl_.clear();
|
|
}
|
|
}
|
|
if (confirmNow && !pendingDl_.empty()) {
|
|
browser_.startDownload(pendingDl_, pendingDlName_, true);
|
|
pendingDl_.clear();
|
|
}
|
|
} else {
|
|
renderer_.setDownloadsModal(false);
|
|
}
|
|
|
|
// 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()) {
|
|
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);
|
|
}
|
|
|
|
auto classifyMediaBadge = [&](const std::string& pathOrName, int w, int h) -> std::string {
|
|
const std::string lower = to_lower(pathOrName);
|
|
const bool is180 = is_vr180_media(pathOrName, w, h);
|
|
const bool is360 = !is180 && is_vr360_media(pathOrName, w, h);
|
|
const bool isTb = contains(lower, "_tb") || contains(lower, "-tb") ||
|
|
contains(lower, "_ou") || contains(lower, "overunder") ||
|
|
contains(lower, "htab");
|
|
const bool isSbs = contains(lower, "sbs") || contains(lower, "_lr") ||
|
|
contains(lower, "sidebyside") || contains(lower, "3d");
|
|
|
|
if (is180) {
|
|
if (w >= 7000 || h >= 3500 || contains(lower, "8k") || contains(lower, "3600") || contains(lower, "4320")) {
|
|
return "VR180 8K";
|
|
}
|
|
if (w >= 5000 || h >= 2500 || contains(lower, "6k") || contains(lower, "5k") ||
|
|
contains(lower, "2880") || contains(lower, "2700") || contains(lower, "5400") || contains(lower, "5760")) {
|
|
return "VR180 6K";
|
|
}
|
|
if (w >= 3600 || h >= 1800 || contains(lower, "4k") || contains(lower, "2160") || contains(lower, "1920")) {
|
|
return "VR180 4K";
|
|
}
|
|
return (isSbs || isTb) ? "VR180 3D" : "VR180";
|
|
}
|
|
|
|
if (is360) {
|
|
if (w >= 7000 || h >= 3500 || contains(lower, "8k")) {
|
|
return "VR360 8K";
|
|
}
|
|
if (w >= 5000 || h >= 2500 || contains(lower, "6k")) {
|
|
return "VR360 6K";
|
|
}
|
|
if (w >= 3600 || h >= 1800 || contains(lower, "4k")) {
|
|
return "VR360 4K";
|
|
}
|
|
return (isSbs || isTb) ? "VR360 3D" : "VR360";
|
|
}
|
|
|
|
if (isTb) return "3D TB";
|
|
if (isSbs) return "3D SBS";
|
|
|
|
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));
|
|
}
|
|
|
|
renderer_.setBrowserNetwork(browser_.onNetwork());
|
|
std::string status = browser_.status();
|
|
if (browser_.onNetwork() && !browser_.isBusy()) {
|
|
if (status.empty()) {
|
|
status = "A/Trigger: Stream \xB7 X: Download to Local";
|
|
}
|
|
} else if (!browser_.onNetwork() && !browser_.isBusy()) {
|
|
if (status.empty()) {
|
|
status = "A/Trigger: Play \xB7 Y: Actions";
|
|
}
|
|
}
|
|
renderer_.setBrowserItems(true, browser_.title(), items,
|
|
browser_.selected(), status);
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
browserOpen = true;
|
|
} else {
|
|
renderer_.setBrowserItems(false, "", {}, 0, "");
|
|
}
|
|
renderer_.setActionMenu(actionMenu_);
|
|
#endif
|
|
|
|
// Settings panel overlay
|
|
if (settings_.isOpen()) {
|
|
std::vector<std::string> sRows;
|
|
std::vector<uint8_t> sEnabled;
|
|
sRows.reserve(settings_.rows().size());
|
|
sEnabled.reserve(settings_.rows().size());
|
|
for (const auto& r : settings_.rows()) {
|
|
sRows.push_back(r.value.empty() ? r.label
|
|
: (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);
|
|
settingsOpen = true;
|
|
} else {
|
|
renderer_.setSettings(false, "", {}, 0, "");
|
|
}
|
|
|
|
// Feed controller poses to the renderer (models, pointer ray, cursor);
|
|
// it raycasts the pointer against UI panels and reports hit rows/buttons.
|
|
{
|
|
// Align each controller model to its runtime render-model space
|
|
// (where the system draws the real controller).
|
|
for (int h = 0; h < 2; ++h) {
|
|
if (!in.controllers[h].gripActive) continue;
|
|
float mPos[3], mQuat[4];
|
|
if (renderModels_.locate(h, xr_.appSpace(), xr_.displayTime(), mPos, mQuat)) {
|
|
const float* gq = in.controllers[h].gripOrientation;
|
|
const float* gt = in.controllers[h].gripPosition;
|
|
const float giq[4] = {-gq[0], -gq[1], -gq[2], gq[3]};
|
|
float git[3];
|
|
quatRotate(giq, gt, git);
|
|
git[0] = -git[0]; git[1] = -git[1]; git[2] = -git[2];
|
|
composePose(git, giq, mPos, mQuat, modelCorrPos_[h], modelCorrQuat_[h]);
|
|
modelCorrValid_[h] = true;
|
|
}
|
|
}
|
|
const bool uiPanelActive = browserOpen || settingsOpen ||
|
|
downloadsWindow_ || !pendingDl_.empty() || actionMenu_.visible;
|
|
// The Transport HUD is only visible during playback when the
|
|
// Unified Spatial Hub and modals are closed.
|
|
const bool barBlocked = browserOpen || settingsOpen || downloadsWindow_ ||
|
|
!pendingDl_.empty() || actionMenu_.visible;
|
|
// Controls (buttons + timeline) show only while a controller is
|
|
// actively moving; they fade after a few seconds of stillness.
|
|
renderer_.setControlBar(!barBlocked && controllersEngaged &&
|
|
(in.controllers[0].active ||
|
|
in.controllers[1].active));
|
|
// Laser + models stay up while a panel is open (to point at it),
|
|
// otherwise they follow the same activity timeout.
|
|
const bool showLaser = uiPanelActive || controllersEngaged;
|
|
// Build each controller's render state: corrected model pose,
|
|
// low-pass smoothed (the raw hand pose jitters), while the aim
|
|
// pose stays raw so the laser remains responsive.
|
|
auto build = [&](int h) {
|
|
const XrInputState::ControllerPose& c = in.controllers[h];
|
|
VulkanRenderer::ControllerVis v;
|
|
v.active = c.active && showLaser;
|
|
for (int k = 0; k < 3; ++k) v.position[k] = c.position[k];
|
|
for (int k = 0; k < 4; ++k) v.orientation[k] = c.orientation[k];
|
|
v.gripActive = c.gripActive;
|
|
float gp[3], gq[4];
|
|
if (c.gripActive && modelCorrValid_[h]) {
|
|
composePose(c.gripPosition, c.gripOrientation,
|
|
modelCorrPos_[h], modelCorrQuat_[h], gp, gq);
|
|
} else {
|
|
for (int k = 0; k < 3; ++k) gp[k] = c.gripPosition[k];
|
|
for (int k = 0; k < 4; ++k) gq[k] = c.gripOrientation[k];
|
|
}
|
|
if (c.gripActive) {
|
|
const float a = 0.4f; // lower = smoother (more lag)
|
|
if (!modelSmoothInit_[h]) {
|
|
modelSmoothInit_[h] = true;
|
|
for (int k = 0; k < 3; ++k) modelSmoothPos_[h][k] = gp[k];
|
|
for (int k = 0; k < 4; ++k) modelSmoothQuat_[h][k] = gq[k];
|
|
} else {
|
|
for (int k = 0; k < 3; ++k)
|
|
modelSmoothPos_[h][k] +=
|
|
(gp[k] - modelSmoothPos_[h][k]) * a;
|
|
quatNlerp(modelSmoothQuat_[h], gq, a,
|
|
modelSmoothQuat_[h]);
|
|
}
|
|
for (int k = 0; k < 3; ++k)
|
|
v.gripPosition[k] = modelSmoothPos_[h][k];
|
|
for (int k = 0; k < 4; ++k)
|
|
v.gripOrientation[k] = modelSmoothQuat_[h][k];
|
|
} else {
|
|
for (int k = 0; k < 3; ++k) v.gripPosition[k] = gp[k];
|
|
for (int k = 0; k < 4; ++k) v.gripOrientation[k] = gq[k];
|
|
}
|
|
return v;
|
|
};
|
|
renderer_.setControllers(build(0), build(1));
|
|
}
|
|
|
|
#ifdef PIXELVR_HAVE_FREETYPE
|
|
// Unified Spatial Hub left navigation rail clicks (shared across Files, Downloads, Settings)
|
|
const bool hubActive = (browserOpen || settingsOpen || downloadsWindow_) &&
|
|
pendingDl_.empty() && !actionMenu_.visible;
|
|
const int sbHover = renderer_.browserSidebarHovered();
|
|
if (hubActive && (in.togglePlay || in.select) && sbHover >= 0) {
|
|
if (sbHover == 0) {
|
|
// 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();
|
|
}
|
|
} else if (sbHover == 7) {
|
|
// Subtab 5: Vault (Private) in Files or Vault & Privacy in Settings
|
|
if (VaultManager::instance().isUnlocked()) {
|
|
if (settings_.isOpen()) {
|
|
settings_.setSection(4);
|
|
} else if (browser_.isOpen()) {
|
|
browser_.openVault();
|
|
}
|
|
}
|
|
} else if (sbHover == 8) {
|
|
// Lock Vault button
|
|
if (VaultManager::instance().isUnlocked()) {
|
|
xr_.triggerHaptic(0, 0.050f, 0.0f, 0.7f);
|
|
xr_.triggerHaptic(1, 0.050f, 0.0f, 0.7f);
|
|
performVaultLock();
|
|
}
|
|
}
|
|
interacted = true;
|
|
}
|
|
|
|
// 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() || browser_.hasQueuedDownloads()) {
|
|
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())) {
|
|
const auto& j = jobs[static_cast<std::size_t>(hov)];
|
|
if (j.state == 1 && !j.localPath.empty()) {
|
|
if (j.isDir) {
|
|
browser_.openPath(j.localPath);
|
|
downloadsWindow_ = false;
|
|
} else {
|
|
#ifdef PIXELVR_HAVE_MEDIA
|
|
loadMedia(j.localPath);
|
|
downloadsWindow_ = false;
|
|
browser_.setOpen(false);
|
|
#endif
|
|
}
|
|
interacted = true;
|
|
} else if (j.state == 4) {
|
|
browser_.cancelQueued(j.uri);
|
|
xr_.triggerHaptic(0, 0.020f, 0.0f, 0.4f);
|
|
xr_.triggerHaptic(1, 0.020f, 0.0f, 0.4f);
|
|
interacted = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// File Action Submenu or Files view interaction inside Unified Spatial Hub
|
|
if (actionMenu_.visible) {
|
|
if (!browser_.isOpen() || downloadsWindow_ || settings_.isOpen() || !pendingDl_.empty()) {
|
|
actionMenu_.visible = false;
|
|
} else {
|
|
const Browser::Entry* fe = browser_.focused();
|
|
|
|
// Button Y (or configured move button) toggles/cancels
|
|
if (actionBtnPressed) {
|
|
actionMenu_.visible = false;
|
|
} else if (actionMenu_.page == 0 || actionMenu_.page == 1) {
|
|
const int dy = navStep(in.thumbstickY, nowT);
|
|
if (dy != 0) {
|
|
actionMenu_.selectedOption += dy;
|
|
const int maxOpt = static_cast<int>(actionMenu_.options.size()) - 1;
|
|
if (actionMenu_.selectedOption < 0) actionMenu_.selectedOption = 0;
|
|
if (actionMenu_.selectedOption > maxOpt) actionMenu_.selectedOption = maxOpt;
|
|
}
|
|
const int hov = renderer_.actionMenuBtnHovered();
|
|
if (hov >= 0) actionMenu_.selectedOption = hov;
|
|
|
|
if (in.select || in.togglePlay) {
|
|
int chosen = (hov >= 0) ? hov : actionMenu_.selectedOption;
|
|
if (chosen >= 0 && chosen < static_cast<int>(actionMenu_.options.size())) {
|
|
const std::string opt = actionMenu_.options[chosen];
|
|
if (actionMenu_.page == 0) {
|
|
if (opt == "Move to Vault") {
|
|
std::string newPath;
|
|
if (browser_.moveFocusedToVault(newPath)) {
|
|
xr_.triggerHaptic(0, 0.050f, 0.0f, 0.7f);
|
|
xr_.triggerHaptic(1, 0.050f, 0.0f, 0.7f);
|
|
}
|
|
actionMenu_.visible = false;
|
|
} else if (opt == "Move To...") {
|
|
actionMenu_.page = 1;
|
|
actionMenu_.title = "Move Destination";
|
|
actionMenu_.subtitle = fe ? fe->label : "";
|
|
actionMenu_.options.clear();
|
|
actionMenu_.options.push_back("Videos (~/Videos)");
|
|
actionMenu_.options.push_back("Downloads (~/Downloads)");
|
|
actionMenu_.options.push_back("Home (~/)");
|
|
if (!browser_.parentDir().empty()) {
|
|
actionMenu_.options.push_back("Parent Folder (..)");
|
|
}
|
|
actionMenu_.options.push_back("Cancel");
|
|
actionMenu_.selectedOption = 0;
|
|
} else if (opt == "Rename") {
|
|
actionMenu_.page = 3;
|
|
actionMenu_.title = (fe && fe->isDir) ? "Rename Folder" : "Rename File";
|
|
actionMenu_.subtitle = fe ? fe->label : "";
|
|
actionMenu_.renameBuffer = fe ? fe->label : "";
|
|
actionMenu_.selectedOption = -1;
|
|
} else if (opt == "Delete") {
|
|
actionMenu_.page = 2;
|
|
actionMenu_.title = (fe && fe->isDir) ? "Delete Folder?" : "Delete File?";
|
|
actionMenu_.subtitle = "Permanently delete \"" + (fe ? fe->label : "") + "\"" +
|
|
((fe && fe->isDir) ? " and all contents?" : "?");
|
|
actionMenu_.options.clear();
|
|
actionMenu_.options.push_back("Yes, Delete");
|
|
actionMenu_.options.push_back("Cancel");
|
|
actionMenu_.selectedOption = 1; // Default to Cancel for safety
|
|
} else if (opt == "Cancel") {
|
|
actionMenu_.visible = false;
|
|
}
|
|
} else if (actionMenu_.page == 1) {
|
|
if (opt == "Cancel") {
|
|
actionMenu_.visible = false;
|
|
} else {
|
|
std::string targetDir;
|
|
if (opt == "Videos (~/Videos)") targetDir = browser_.videosDir();
|
|
else if (opt == "Downloads (~/Downloads)") targetDir = browser_.downloadsDir();
|
|
else if (opt == "Home (~/)") targetDir = browser_.homeDir();
|
|
else if (opt == "Parent Folder (..)") targetDir = browser_.parentDir();
|
|
|
|
if (!targetDir.empty()) {
|
|
std::string newPath;
|
|
if (browser_.moveFocusedTo(targetDir, newPath)) {
|
|
xr_.triggerHaptic(0, 0.050f, 0.0f, 0.7f);
|
|
xr_.triggerHaptic(1, 0.050f, 0.0f, 0.7f);
|
|
}
|
|
}
|
|
actionMenu_.visible = false;
|
|
}
|
|
}
|
|
}
|
|
interacted = true;
|
|
}
|
|
} else if (actionMenu_.page == 2) {
|
|
const int dx = navStepX(in.thumbstickX, nowT);
|
|
const int dy = navStep(in.thumbstickY, nowT);
|
|
if (dx != 0 || dy != 0) {
|
|
actionMenu_.selectedOption = (actionMenu_.selectedOption == 0) ? 1 : 0;
|
|
}
|
|
const int hov = renderer_.actionMenuBtnHovered();
|
|
if (hov >= 0) actionMenu_.selectedOption = hov;
|
|
|
|
if (in.select || in.togglePlay) {
|
|
int chosen = (hov >= 0) ? hov : actionMenu_.selectedOption;
|
|
if (chosen == 0) {
|
|
std::string delName;
|
|
if (browser_.deleteFocused(delName)) {
|
|
xr_.triggerHaptic(0, 0.050f, 0.0f, 0.7f);
|
|
xr_.triggerHaptic(1, 0.050f, 0.0f, 0.7f);
|
|
}
|
|
actionMenu_.visible = false;
|
|
} else {
|
|
actionMenu_.visible = false;
|
|
}
|
|
interacted = true;
|
|
}
|
|
} else if (actionMenu_.page == 3) {
|
|
const int btnHov = renderer_.actionMenuBtnHovered();
|
|
const int keyHov = renderer_.actionMenuKeyHovered();
|
|
|
|
if (in.select || in.togglePlay) {
|
|
if (btnHov == 0) {
|
|
actionMenu_.renameBuffer.clear();
|
|
xr_.triggerHaptic(0, 0.020f, 0.0f, 0.5f);
|
|
xr_.triggerHaptic(1, 0.020f, 0.0f, 0.5f);
|
|
} else if (btnHov == 1) {
|
|
if (!actionMenu_.renameBuffer.empty()) {
|
|
actionMenu_.renameBuffer.pop_back();
|
|
xr_.triggerHaptic(0, 0.020f, 0.0f, 0.5f);
|
|
xr_.triggerHaptic(1, 0.020f, 0.0f, 0.5f);
|
|
}
|
|
} else if (btnHov == 2) {
|
|
if (!actionMenu_.renameBuffer.empty()) {
|
|
if (browser_.renameFocused(actionMenu_.renameBuffer)) {
|
|
xr_.triggerHaptic(0, 0.050f, 0.0f, 0.7f);
|
|
xr_.triggerHaptic(1, 0.050f, 0.0f, 0.7f);
|
|
}
|
|
actionMenu_.visible = false;
|
|
}
|
|
} else if (btnHov == 3) {
|
|
actionMenu_.visible = false;
|
|
} else if (keyHov >= 0) {
|
|
static const char* kKeys = "1234567890-_QWERTYUIOPASDFGHJKLZXCVBNM. ";
|
|
if (keyHov < static_cast<int>(std::strlen(kKeys))) {
|
|
actionMenu_.renameBuffer.push_back(kKeys[keyHov]);
|
|
xr_.triggerHaptic(0, 0.015f, 0.0f, 0.4f);
|
|
xr_.triggerHaptic(1, 0.015f, 0.0f, 0.4f);
|
|
}
|
|
}
|
|
interacted = true;
|
|
}
|
|
}
|
|
}
|
|
} else if (actionBtnPressed && browser_.isOpen() && !downloadsWindow_ &&
|
|
!settings_.isOpen() && pendingDl_.empty() && !browser_.onNetwork()) {
|
|
openActionMenu();
|
|
interacted = true;
|
|
} else if (browser_.isOpen() && !browser_.isScanning() && !downloadsWindow_ &&
|
|
!settings_.isOpen() && pendingDl_.empty()) {
|
|
// 2D grid thumbstick navigation: Y steps rows (3 cards), X steps columns (1 card)
|
|
const int dy = navStep(in.thumbstickY, nowT);
|
|
const int dx = navStepX(in.thumbstickX, nowT);
|
|
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: download the highlighted entry when browsing SMB shares. Folders ask for
|
|
// confirmation (with the measured size) first; plain files download immediately.
|
|
if (downloadPressed && browser_.isOpen() && !downloadsWindow_ && !settings_.isOpen() &&
|
|
pendingDl_.empty() && !actionMenu_.visible && browser_.onNetwork()) {
|
|
const Browser::Entry* fe = browser_.focused();
|
|
if (fe != nullptr && fe->isDir && !fe->isBack) {
|
|
pendingDl_ = fe->path;
|
|
pendingDlName_ = fe->label;
|
|
pendingDlFresh_ = true;
|
|
browser_.measureFolder(fe->path);
|
|
} else {
|
|
browser_.downloadFocused();
|
|
}
|
|
}
|
|
#endif
|
|
|
|
if (settings_.isOpen() && sbHover < 0) {
|
|
if (const int d = navStep(in.thumbstickY, nowT)) {
|
|
settings_.move(d * 2);
|
|
}
|
|
if (const int dx = navStepX(in.thumbstickX, nowT)) {
|
|
settings_.move(dx);
|
|
}
|
|
|
|
const int hovered = renderer_.settingsHovered();
|
|
if (hovered >= 0) {
|
|
renderer_.setSettingsSelected(hovered);
|
|
settings_.setSelected(hovered);
|
|
}
|
|
// Trigger activates the selection made by the laser or the stick.
|
|
if (in.select && (hovered >= 0 || std::fabs(in.thumbstickY) > 0.25f)) {
|
|
auto act = settings_.activate();
|
|
if (act == SettingsMenu::Action::LockVaultAction) {
|
|
xr_.triggerHaptic(0, 0.050f, 0.0f, 0.7f);
|
|
xr_.triggerHaptic(1, 0.050f, 0.0f, 0.7f);
|
|
performVaultLock();
|
|
} else if (act == SettingsMenu::Action::RecenterScreen) {
|
|
if (in.headValid) {
|
|
renderer_.recenterScreen(in.headPos, in.headForward);
|
|
}
|
|
} else if (act == SettingsMenu::Action::CycleProjection) {
|
|
int pm = settings_.projectionMode();
|
|
renderer_.setProjectionMode(
|
|
pm == 0 ? VulkanRenderer::ProjectionMode::Flat :
|
|
pm == 1 ? VulkanRenderer::ProjectionMode::Equirect360 :
|
|
VulkanRenderer::ProjectionMode::Equirect180);
|
|
} else if (act == SettingsMenu::Action::CycleStereo) {
|
|
int sm = settings_.stereoMode();
|
|
renderer_.setStereoMode(
|
|
sm == 0 ? VulkanRenderer::StereoMode::Mono :
|
|
sm == 1 ? VulkanRenderer::StereoMode::SideBySide :
|
|
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 ||
|
|
act == SettingsMenu::Action::PickAudioStream) {
|
|
switchTracks(false);
|
|
} else if (act == SettingsMenu::Action::PickLanguage) {
|
|
switchTracks(true);
|
|
}
|
|
#endif
|
|
}
|
|
if (in.recenter && in.headValid) {
|
|
renderer_.recenterScreen(in.headPos, in.headForward);
|
|
}
|
|
}
|
|
|
|
// 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();
|
|
// 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
|
|
media_.close();
|
|
mediaOpen_ = false;
|
|
renderer_.clearVideo();
|
|
#endif
|
|
#ifdef PIXELVR_HAVE_FREETYPE
|
|
browser_.open(); // large file selection
|
|
#endif
|
|
} else if (cb == VulkanRenderer::CtrlButton::Settings) {
|
|
settings_.open();
|
|
} 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
|
|
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) {
|
|
media_.togglePause();
|
|
} else if (mediaOpen_ &&
|
|
cb == VulkanRenderer::CtrlButton::SeekBack) {
|
|
media_.seekRelative(-10.0);
|
|
} 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;
|
|
}
|
|
if (in.select && renderer_.pointerOnTimeToggle()) {
|
|
renderer_.toggleTimeDisplay(); // total <-> time remaining
|
|
interacted = true;
|
|
}
|
|
}
|
|
// Viewpoint / transport controls only when no panel is open (the
|
|
// thumbstick drives in-panel navigation otherwise).
|
|
if (barOk && !browserOpen) {
|
|
if (in.recenter && in.headValid) {
|
|
renderer_.recenterScreen(in.headPos, in.headForward);
|
|
interacted = true;
|
|
}
|
|
// Viewpoint: flat screens move nearer/farther (thumbstick Y);
|
|
// immersive 360/180 uses the RIGHT stick -- up/down tilts (angle),
|
|
// left/right pushes content nearer/farther (distance).
|
|
if (settings_.projectionMode() == 0) {
|
|
if (in.thumbstickY > 0.15f || in.thumbstickY < -0.15f) {
|
|
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);
|
|
interacted = true;
|
|
}
|
|
if (std::fabs(in.thumbstickRX) > 0.15f) {
|
|
renderer_.adjustSphereZoom(in.thumbstickRX * dt * 0.6f);
|
|
interacted = true;
|
|
}
|
|
}
|
|
#ifdef PIXELVR_HAVE_MEDIA
|
|
if (in.togglePlay && mediaOpen_) {
|
|
media_.togglePause();
|
|
interacted = true;
|
|
}
|
|
if (mediaOpen_) {
|
|
// Right stick left/right steps -/+30s (flat only; in 360/180 the
|
|
// right stick drives the viewpoint instead).
|
|
constexpr double kStep = 30.0;
|
|
const bool sphereMode = settings_.projectionMode() != 0;
|
|
if (!sphereMode && in.thumbstickRX > 0.6f && seekStepArmed_) {
|
|
media_.seekRelative(kStep);
|
|
seekStepArmed_ = false;
|
|
interacted = true;
|
|
} else if (!sphereMode && in.thumbstickRX < -0.6f && seekStepArmed_) {
|
|
media_.seekRelative(-kStep);
|
|
seekStepArmed_ = false;
|
|
interacted = true;
|
|
} else if (std::fabs(in.thumbstickRX) < 0.3f) {
|
|
seekStepArmed_ = true;
|
|
}
|
|
|
|
// Scrub: accumulate a target while dragging and commit it with a
|
|
// single seek on release. Seeking continuously floods the
|
|
// hardware decoder (a reopen each) and stalls it; one seek per
|
|
// gesture stays responsive.
|
|
bool scrubInput = false;
|
|
if (renderer_.pointerOnTimeline() && in.selectHeld &&
|
|
media_.duration() > 0.0) {
|
|
scrubTarget_ = renderer_.pointerScrub() * media_.duration();
|
|
scrubInput = true;
|
|
} else if (std::fabs(in.thumbstickLX) > 0.15f && dt > 0.0f &&
|
|
media_.duration() > 0.0) {
|
|
if (!scrubbing_) scrubTarget_ = media_.position();
|
|
const float ax = in.thumbstickLX;
|
|
const float t = (std::fabs(ax) - 0.15f) / 0.85f;
|
|
const double rate =
|
|
std::copysign(static_cast<double>(t * t) * 60.0, ax);
|
|
scrubTarget_ = std::clamp(scrubTarget_ + rate * dt, 0.0,
|
|
media_.duration());
|
|
scrubInput = true;
|
|
}
|
|
if (scrubInput) {
|
|
scrubbing_ = true;
|
|
interacted = true;
|
|
} else if (scrubbing_) {
|
|
media_.seek(scrubTarget_); // commit the scrub with one seek
|
|
scrubbing_ = false;
|
|
}
|
|
}
|
|
if (interacted) {
|
|
lastControllerActivity_ = nowT;
|
|
}
|
|
const bool paused = mediaOpen_ && media_.paused();
|
|
const bool controlsUp =
|
|
mediaOpen_ &&
|
|
(paused || (controllersEngaged && (in.controllers[0].active ||
|
|
in.controllers[1].active)));
|
|
float progress = 0.0f;
|
|
double elapsed = 0.0, total = 0.0;
|
|
if (mediaOpen_ && media_.duration() > 0.0) {
|
|
total = media_.duration();
|
|
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;
|
|
#endif
|
|
}
|
|
|
|
#ifdef PIXELVR_HAVE_MEDIA
|
|
if (settings_.showStats()) {
|
|
updateStats();
|
|
}
|
|
// Always-on periodic perf log so decode-bound vs render-bound stalls
|
|
// show up in the run log even without the on-screen overlay.
|
|
if (mediaOpen_) {
|
|
++perfFrames_;
|
|
if (perfLastLog_.time_since_epoch().count() == 0) {
|
|
perfLastLog_ = now;
|
|
perfLastDecoded_ = media_.decodedFrames();
|
|
} else {
|
|
const double since =
|
|
std::chrono::duration<double>(now - perfLastLog_).count();
|
|
if (since >= 2.0) {
|
|
const uint64_t dec = media_.decodedFrames();
|
|
const double decFps =
|
|
(dec >= perfLastDecoded_)
|
|
? static_cast<double>(dec - perfLastDecoded_) / since
|
|
: 0.0;
|
|
PIXELVR_LOG_INFO(
|
|
"PERF: render %.1f fps, decode %.1f/%.2f fps",
|
|
perfFrames_ / since, decFps, media_.frameRate());
|
|
perfFrames_ = 0;
|
|
perfLastDecoded_ = dec;
|
|
perfLastLog_ = now;
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
renderer_.setStats(settings_.showStats(), statsLines_);
|
|
} else {
|
|
// Idle until the runtime tells us to start rendering.
|
|
std::this_thread::sleep_for(50ms);
|
|
}
|
|
}
|
|
|
|
if (renderer_.device() != VK_NULL_HANDLE) {
|
|
vkDeviceWaitIdle(renderer_.device());
|
|
}
|
|
renderModels_.shutdown(); // destroy XR model handles before the session
|
|
PIXELVR_LOG_INFO("Shutting down");
|
|
return 0;
|
|
}
|
|
|
|
} // namespace pixelvr
|