#include "app/app.hpp" #include "app/vault_manager.hpp" #include "util/logging.hpp" #include #include #include #include #include #include #include #include #include #include #include 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(std::tolower(static_cast(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(w) / static_cast(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 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(w) / static_cast(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(proj)); settings_.setStereoMode(static_cast(stereo)); // Publish selectable tracks for the settings Audio/Video section. std::vector> 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(proj)], kStereo[static_cast(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(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(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(dtot - didle) / static_cast(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(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 ns; if (renderModels_.nodeStates(h, xr_.displayTime(), ns)) { const auto& names = renderModels_.nodeNames(h); std::vector 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 drows; std::vector 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 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(static_cast(j.bytes) / static_cast(j.total)) : (j.state == 1 ? 1.0f : 0.0f); if (j.state == 0) { const int pct = j.total > 0 ? static_cast(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(j.total - j.bytes) / browser_.downloadSpeed(); if (eta < 0.0) eta = 0.0; const int secs = static_cast(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 crows{"Download this folder?", pendingDlName_, sz}; std::vector 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 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(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(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 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 sRows; std::vector 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(jobs.size())) { const auto& j = jobs[static_cast(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(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(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(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(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(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(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(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(std::tolower(static_cast(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(std::toupper(static_cast(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(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(now - perfLastLog_).count(); if (since >= 2.0) { const uint64_t dec = media_.decodedFrames(); const double decFps = (dec >= perfLastDecoded_) ? static_cast(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