From 81971fa196f04a13e735ead85c972ca24f5ebc98 Mon Sep 17 00:00:00 2001 From: A Million Bouncy Balls Date: Sat, 1 Aug 2026 06:44:24 +1000 Subject: [PATCH 1/2] Fix #622: Linux client settings GUI (#647) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ⚠️ WARNING — DEFAULT CACHE LIMIT IS 10 GB ⚠️ settings.content.cache_size defaults to 10 GB (CacheManager.cpp). On first run with a large existing cache the eviction logic will PERMANENTLY DELETE cached dream videos until the total is under 10 GB. If you have a large cache, set this key BEFORE running: ~/.config/infinidream/settings.json: "settings.content.cache_size": This is pre-existing behaviour, not introduced by this commit, but newly relevant as Linux users may be running the client for the first time after a long beta period with accumulated content. Linux: add GUI sign-in wizard (closes #622) Implements a 3-step ImGui sign-in wizard for the Linux client, replacing the previous console prompt. Matches the look and flow of the Mac/Windows wizard (ported from FirstTimeSetupWin32.cpp). New files: client_generic/Client/FirstTimeSetupVulkan.h client_generic/Client/FirstTimeSetupVulkan.cpp 3-step wizard: email entry → OTP verification → tips/completion. Light-sheet ImGui style matching Mac/Windows appearance. System font loading with fallbacks: NotoSans → DejaVu → Liberation → ImGui default. Keyboard input via xkb_state_key_get_utf8 for full UTF-8/ international layout support. Clipboard paste via Ctrl+V and Shift+Insert, backed by wl-paste with xclip/xsel fallbacks. Async OTP send/verify with atomic completion flags. Playback paused while wizard is visible, restored on close. Modified: EDreamClient.cpp — ESHasFirstTimeSetupCallback() added so the console login path can be skipped when the GUI wizard is registered. client.h — Linux pre-window auth block now skips the console prompt entirely when the GUI wizard is registered. client_linux.h — Registers the wizard callback before CElectricSheep::Startup() runs; suppresses event loop while wizard is visible. RendererVulkan.cpp — Calls FirstTimeSetupVulkan_DrawIfNeeded() inside the existing ImGui frame. DisplayVulkan.cpp — Feeds keyboard and mouse Wayland events into the wizard. Renamed mouse-moved callback (s_mouseCallback → s_onMouseMovedCallback) for clarity. PlatformUtils_Internal.h / PlatformUtils_Linux.cpp — Renamed PlatformUtils_GetMouseCallback() to PlatformUtils_GetOnMouseMovedCallback(). CMakeLists.txt — Added FirstTimeSetupVulkan.cpp to sources. Co-Authored-By: Claude Sonnet 4.6 * Linux: settings dialog, playlist status in Disk tab, downloader warning, ops runbook Three changes born from a debugging session where the client appeared to be stuck downloading nothing despite healthy quota and disk space. Root cause was the account being assigned a 4-dream developer test playlist server-side; the downloader was correctly idle because everything in that playlist was already cached. None of this was visible without reading logs carefully. 1. Linux settings dialog (SettingsDialogVulkan) Full ImGui-based settings dialog for Linux, mirroring the Windows/macOS equivalents. Tabs: Account, Controls, Disk, Display, Advanced. 2. Playlist status in Disk tab The Disk tab now shows a "Playlist" row with the current playlist name, cached/total dream count (e.g. "4 / 164 downloaded"), and the live downloader status string. A misconfigured account — stuck on a test playlist with everything already cached — is now immediately visible when opening Settings, without needing to grep log files. 3. Downloader warning when all playlist dreams are cached DreamDownloader::FindDreamsThread now logs a one-shot warning when getNextUncachedDream() returns null while quota is still healthy: "All N dreams in playlist \"...\" are already cached — nothing to download. If unexpected, check your server-side playlist assignment (remaining quota: X.XX GB)." Also sets the download status string to "All playlist dreams cached" so it surfaces in the settings dialog immediately. 4. FORCE_PLAYLIST_CHANGE_RUNBOOK.md Documents the full procedure for switching a user's active playlist. There is no REST endpoint for this — it requires a Socket.IO event on the /remote-control namespace (not the default / namespace, which silently accepts connections but ignores all events). Includes the Python script, how to find production playlist UUIDs, confirmation steps, and the key facts that make this non-obvious. Co-Authored-By: Claude Sonnet 4.6 * fix: Linux .gitignore cleanup and Mac/Windows auth regression LinuxBuild/.gitignore was missing rules for in-source CMake artifacts (CMakeCache.txt, CMakeFiles/, Makefile, cmake_install.cmake), runtime assets copied to the build root by CMake (config.xrc, shaders/, *.png, *.ttf, *.xpm), generated Wayland protocol glue (xdg-shell-client-protocol.h/c), and runtime caches (deps-cache/, model-cache/, build-rife/). These all accumulate when someone runs cmake . in-place instead of cmake -B build. Stale leftovers from a previous in-source build have been deleted rather than ignored where appropriate. Fix a regression introduced in the previous commit where EDreamClient::Authenticate() guarded LoginWithMagicLinkCode() with !ESHasFirstTimeSetupCallback() without a LINUX_GNU guard. On Mac, ESWindow.mm registers the first-time setup callback unconditionally at window creation; Windows does the same in FirstTimeSetupWin32.cpp. This caused ESHasFirstTimeSetupCallback() to always return true on both platforms, silently skipping LoginWithMagicLinkCode() — a regression from prior behaviour. The guard is now wrapped in #ifdef LINUX_GNU so only the Linux code path skips the console auth flow when a GUI wizard is registered. Co-Authored-By: Claude Sonnet 4.6 * Remove FORCE_PLAYLIST_CHANGE_RUNBOOK.md * Linux: fix SettingsDialogVulkan dead code, X11 overlay input, HiDPI scaling, and wizard button sizing Four issues identified in PR #647 code review, plus a wizard trigger regression and button sizing follow-up, all fixed in this commit. 1. SettingsDialogVulkan was compiled but never wired up - Added SettingsDialogVulkan.cpp to CMakeLists.txt sources - SettingsDialogVulkan_Register() called in CElectricSheep_Linux::Startup() - SettingsDialogVulkan_DrawIfNeeded() called in RendererVulkan::EndFrame() immediately after the wizard draw, before ImGui::Render() - IsVisible() guard added to HandleOneEvent() so the settings dialog blocks game key bindings while open (mirrors the existing wizard guard) - Ctrl+, mapped to ESShowPreferences() via new KEY_Comma case (XK_comma on X11, hardcoded evdev 51 on Wayland) 2. X11 sessions got a dead, inescapable overlay FeedKey / FeedMousePos / FeedMouseButton were only wired to Wayland callbacks. On X11 the wizard (and now settings dialog) rendered but accepted no input. - X11 KeyPress/KeyRelease: keysym saved before XFree(); FeedKey called for both overlays with evdevKey = keycode - 8 and nullptr xkb_state; event is consumed (continue) if either overlay accepts it - X11 MotionNotify: FeedMousePos forwarded to both overlays - X11 ButtonPress/ButtonRelease: X11 button numbers mapped to BTN_LEFT/ RIGHT/MIDDLE (272/273/274) and forwarded via FeedMouseButton 3. Fonts hardcoded at 1.0× scale (no HiDPI) - New PlatformUtils_InitUIScale / GetUIScale / SetUIScale in PlatformUtils_Linux.cpp; scale stored as g_platformUIScale (clamped 1–4×) - InitUIScale() seeds from GDK_SCALE / QT_SCALE_FACTOR env vars so GNOME / KDE fractional scaling sessions get a reasonable default immediately - Wayland: wl_output listener bound in onRegistryGlobal(); geometry and mode events collect physWidthMm and pixelWidth; done event computes DPI = pixelWidth * 25.4 / physWidthMm and derives scale = DPI / 96. Integer wl_output.scale used as fallback when physical dimensions are absent. Multi-monitor: SetUIScale only called when the new value exceeds the current one, so a lower-DPI secondary monitor cannot overwrite the primary HiDPI scale. - X11: DisplayWidthMM() queried after XOpenDisplay; DPI computed the same way and passed to SetUIScale() - FirstTimeSetupVulkan and SettingsDialogVulkan both refresh g_uiScale from PlatformUtils_GetUIScale() at show time, before LoadWizardFonts / LoadDialogFonts, so fonts are always sized for the actual display DPI 4. Wrong issue reference in PR title — already fixed before this commit 5. Wizard not appearing after auth token cleared EDreamClient::Authenticate() guarded ESShowFirstTimeSetup() behind settings.app.firsttimesetup == false. Users who had previously completed setup (flag = true) never saw the wizard again after clearing their token. The guard is removed; the existing shownSettingsOnce per-session bool is sufficient to prevent repeated shows within one run. (Affects all platforms that share EDreamClient.cpp — macOS and Windows handled the flag separately in FirstTimeSetupManager.mm and were unaffected.) 6. Wizard button and input field sizing All interactive controls on the email and code steps were rendered in the 15px body font, which looked too small relative to the 28px title text and the overall dialog proportions. - New g_fontButton loaded at S(20.f) (20 logical px at 96 DPI, scaled by PlatformUtils_GetUIScale(); e.g. ~23px on a 109 DPI display) - Applied via PushFont/PopFont to: Skip, Send code, Need an account?, Verify code, Try again. Send code uses a manual AddText call so it receives the explicit font pointer and size via CalcTextSizeA / AddText(font, size, ...) - Email input field also uses g_fontButton; y frame-padding reduced from S(10) to S(8) so field height = S(20) + 2×S(8) = S(36), flush with buttons - Button widths widened to fit the larger label text: kEmailSendBtnW 106→120, kEmailCreateAccountW 235→300, kCodeVerifyW 110→150, kCodeTryAgainW 88→120 Co-Authored-By: Claude Sonnet 4.6 * Linux wizard: fix OTP step layout and bump all interactive text to button font Three issues on the code-entry (OTP) step: 1. Error message was tiny and had no room kCodeErrorOffsetY=144 placed the error immediately at the bottom of the OTP input field (which ends at ~y=146), and kCodeVerifyOffsetY=158 left only 14px for the error text before the Verify button — far too little for even a single line of body text, let alone a wrapped multi-line message. Replaced the fixed-Y layout for the error+buttons section with flow-based positioning: cursor is set to kCodeAfterOtpY=152 (just below the OTP field), the error message (if present) renders naturally and pushes buttons down via Dummy() spacing, and Verify/Try again follow in flow order. kCodeStepPanelH expanded from 250 to 330 to give the flow room (worst case: 3 lines of error + spacing + two buttons ≈ 280px from kCodeAfterOtpY). Removed now-unused constants kCodeErrorOffsetY, kCodeVerifyOffsetY, kCodeTryAgainOffsetY. 2. Error message and "Verifying..." were in body font (S(15.f)) Both now use g_fontButton (S(20.f)), consistent with buttons and the email input field. "Verifying..." renders via SameLine after the Verify button instead of an absolute SetCursorPos. 3. Email step validation error was also body font "Please enter your email address" / "Please enter a valid email address" now also rendered in g_fontButton. Co-Authored-By: Claude Sonnet 4.6 * Linux: fix X11 ButtonReleaseMask, stale Escape in settings, Wayland Ctrl+,, and cursor visibility - X11: add ButtonReleaseMask to XSelectInput so ImGui sees mouse-up events; without it the first click left the button "held" and blocked all subsequent clicks - SettingsDialogVulkan: call ClearInputKeys() on show to discard stale Escape state carried over from closing the wizard - Wayland: add XKB_KEY_comma to keysym switch so Ctrl+, reaches HandleOneEvent - X11: add applyDefaultCursor() and toggle cursor visibility per-frame while any ImGui overlay is open Co-Authored-By: Claude Sonnet 4.6 * Linux: settings dialog and wizard UI polish — fonts, layout, and overlays Settings dialog font fix (root cause): ImGui 1.92 introduced FontSizeBase, which is initialised to 13 px (ProggyClean) on the very first rendered frame before any custom fonts are loaded. Setting io.FontDefault alone does not change FontSizeBase, so all settings dialog text was silently rendered at 13 px regardless of the font loaded at S(20). Fix: wrap DrawSettingsDialog() in PushFont(g_regularUiFont, S(20.f))/PopFont() so the font and its size are both active for the entire dialog, matching the pattern the wizard already uses for every explicit PushFont call. Also store the loaded font in g_regularUiFont and re-apply io.FontDefault on every open so a prior wizard run cannot leave a stale S(15) body-font pointer as the default. Settings dialog layout changes: - Font size bumped from S(17) to S(20) across all tabs (NotoSans loaded from system fonts; falls back to embedded ProggyClean if none found). Diagnostic log lines record which font file was loaded and at what pixel size. - Dialog height increased from S(390) to S(450) to accommodate taller text. - Footer: Close button grown to S(90)×S(32), "?" button to S(24); the "?" is drawn via InvisibleButton + AddText on the draw list for pixel-perfect glyph centering rather than relying on ImGui button frame padding. - Version string simplified to GetAppVersion() only (no git hash / build date); the real version will come from -DAPP_VERSION=X.Y.Z at release tag time. - Controls tab: removed SetWindowFontScale(14/17) and all companion calls; section height S(160)→S(240), footer row height S(34)→S(40); button label "View Help Page" corrected to "View help page". - Account tab: "Signed in as" text and "Sign out" button placed on the same row (previously on separate rows); dotRadius scaled via S(); action button height S(30)→S(36), width S(100)→S(110); formHeight for logged-in S(92)→S(100). - Disk tab: fixed playlist panel text overwriting itself — the old code used GetItemRectMax().x (screen-space) as a window-local cursor position, pushing text off-screen. Rewritten to use GetContentRegionAvail().x inside the child window for right-aligning the count, with SameLine for the playlist name and a clamped wrap position to prevent overlap. playlistGroupH S(62)→S(72). - Display tab: "Preserve Aspect Ratio" checkbox centred in the available area instead of being offset by fixed S(140)/S(80) constants. - Advanced/Proxy tab: removed SetWindowFontScale(14/17); proxyContentInsetY S(22)→S(38) so "Use Proxy" clears the bold "Proxy" header; proxyGroupH S(160)→S(200) so the Password row is no longer clipped. Wizard changes: - Card width narrowed from 879→700 to match the settings dialog footprint. - Card height S(580)→S(620) to absorb the taller S(20) tips text. - "All set!" tips panel row heights grown (kThanksRowTopH 110→215, kThanksRowBotH 92→132) and copy/button font sizes unified at kUiTextSize=20. - "Open web remote" and "Open playlist browser" button widths widened (118→186, 136→224) so the labels are not clipped at S(20) font. Lato font regression fix (HUD overlay): FontVulkan.cpp previously always used the embedded ProggyClean/ProggyForever vector font. Fixed to first attempt loading Lato-Regular.ttf from the same directory as the running executable (where the build system already copies it), falling back to the embedded font only when the file is absent. CMake / build data: - Added configure_file step that generates BuildData.json in the binary directory at cmake-configure time. APP_VERSION defaults to "0.0.0" and is overridden via -DAPP_VERSION=X.Y.Z when cutting a release. Also captures git short SHA and build date for internal reference. - New BuildData.json.in template added alongside CMakeLists.txt. Co-Authored-By: Claude Sonnet 4.6 * Linux: fix playlist playback freezes and Vulkan swapchain loop Playback stability: - UpdateTransition: reorder snap/freeze so HasFinished fires first — previous order let nextClipBuffering's early return block the snap, leaving a stalled outgoing clip frozen even when the incoming clip is also buffering - Player::Update: don't consume m_nextClip frames until the crossfade starts; extends the existing seamless guard to all preloaded clips so the decoder fills the queue to its backpressure cap and playback begins near frame 0 instead of mid-stream (~3 s of content reclaimed per clip, giving preloading proportionally more lead time) - IsPreloadComplete: lower threshold 10 → 5 frames to match the Rebuffering exit point and avoid discarding a live preloaded decoder for a fresh one - Decoder: reset m_SeekTargetFrame = -1 after seek to stop re-seek loop that caused 21-second stalls; add av_bsf_flush after seek; diagnostic warnings for slow av_read_frame and EAGAIN spin Vulkan swapchain: - Respect driver-enforced currentExtent (required by spec); call SyncSize() after recreation so BeginFrame's mismatch check doesn't immediately trigger another recreation loop - Skip frames when extent is zero (window minimised on some WMs) - Add CDisplayOutput::SyncSize() for post-recreation size sync Co-Authored-By: Claude Sonnet 4.6 * Fix Linux playlist switching and Vulkan lifecycle Serialize remote playlist work onto the player update thread, harden transition readiness, and correct X11/Vulkan synchronization and shutdown resource ordering. * Prevent playlist switches from blocking on remote media Prepare playlist metadata and streaming URLs on the network worker before applying a queued playlist change. Reuse the prepared URL when the player parses the playlist so server requests cannot stall the render thread. Open remote FFmpeg decoders on their frame-reading worker and add interrupt callbacks for stop and shutdown requests. Keep local decoder opens synchronous so missing or corrupt cached files still fail immediately. Propagate asynchronous open failures to clips and cancel incomplete transitions cleanly instead of waiting forever for frames. Also add a short backoff after download-link failures to prevent tight retry loops, especially while shutting down. Validated with a Linux build and a 100-second X11 playlist-switch stress run covering seven switches and four successful remote decoder opens, including one that took over five seconds without blocking playlist processing. * Package build metadata in Linux AppImage Copy the CMake-generated BuildData.json beside the packaged executable so PlatformUtils can resolve the version, Git revision, and build date exactly as it does for an unpackaged Linux build. Verified by rebuilding and launching the AppImage; the runtime banner reports revision e06dbfff and build date 2026-07-22. --------- Co-authored-by: Claude Sonnet 4.6 --- .../Client/FirstTimeSetupVulkan.cpp | 1094 +++++++++++++++++ client_generic/Client/FirstTimeSetupVulkan.h | 35 + client_generic/Client/Player.cpp | 682 ++++++---- client_generic/Client/Player.h | 63 +- .../Client/SettingsDialogVulkan.cpp | 556 +++++++++ client_generic/Client/SettingsDialogVulkan.h | 34 + ...ttingsDialogVulkan.CommonSettingsState.inl | 85 ++ .../SettingsDialogVulkan.CommonTextUi.inl | 141 +++ .../SettingsDialogVulkan.DialogLifecycle.inl | 45 + .../SettingsDialogVulkan.TabAccount.inl | 243 ++++ .../SettingsDialogVulkan.TabAdvanced.inl | 82 ++ .../SettingsDialogVulkan.TabControls.inl | 77 ++ .../SettingsDialogVulkan.TabDisk.inl | 140 +++ .../SettingsDialogVulkan.TabDisplay.inl | 11 + client_generic/Client/client.h | 37 +- client_generic/Client/client_linux.h | 17 + client_generic/ContentDecoder/Clip.cpp | 21 +- client_generic/ContentDecoder/Clip.h | 12 +- .../ContentDecoder/ContentDecoder.cpp | 134 +- .../ContentDecoder/ContentDecoder.h | 8 +- .../ContentDownloader/CacheManager.cpp | 2 +- .../ContentDownloader/DreamDownloader.cpp | 24 +- .../ContentDownloader/PlaylistManager.cpp | 242 ++-- .../ContentDownloader/PlaylistManager.h | 56 +- client_generic/DisplayOutput/DisplayOutput.h | 4 + .../DisplayOutput/Renderer/Renderer.h | 6 +- .../DisplayOutput/Vulkan/DisplayVulkan.cpp | 162 ++- .../DisplayOutput/Vulkan/DisplayVulkan.h | 1 + .../DisplayOutput/Vulkan/RendererVulkan.cpp | 120 +- .../DisplayOutput/Vulkan/RendererVulkan.h | 9 +- client_generic/LinuxBuild/BuildData.json.in | 5 + client_generic/LinuxBuild/CMakeLists.txt | 31 + .../LinuxBuild/PlatformUtils_Internal.h | 7 + .../LinuxBuild/PlatformUtils_Linux.cpp | 36 + client_generic/LinuxBuild/build_appimage.py | 4 + client_generic/Networking/EDreamClient.cpp | 58 +- 36 files changed, 3827 insertions(+), 457 deletions(-) create mode 100644 client_generic/Client/FirstTimeSetupVulkan.cpp create mode 100644 client_generic/Client/FirstTimeSetupVulkan.h create mode 100644 client_generic/Client/SettingsDialogVulkan.cpp create mode 100644 client_generic/Client/SettingsDialogVulkan.h create mode 100644 client_generic/Client/SettingsDialogVulkan/SettingsDialogVulkan.CommonSettingsState.inl create mode 100644 client_generic/Client/SettingsDialogVulkan/SettingsDialogVulkan.CommonTextUi.inl create mode 100644 client_generic/Client/SettingsDialogVulkan/SettingsDialogVulkan.DialogLifecycle.inl create mode 100644 client_generic/Client/SettingsDialogVulkan/SettingsDialogVulkan.TabAccount.inl create mode 100644 client_generic/Client/SettingsDialogVulkan/SettingsDialogVulkan.TabAdvanced.inl create mode 100644 client_generic/Client/SettingsDialogVulkan/SettingsDialogVulkan.TabControls.inl create mode 100644 client_generic/Client/SettingsDialogVulkan/SettingsDialogVulkan.TabDisk.inl create mode 100644 client_generic/Client/SettingsDialogVulkan/SettingsDialogVulkan.TabDisplay.inl create mode 100644 client_generic/LinuxBuild/BuildData.json.in diff --git a/client_generic/Client/FirstTimeSetupVulkan.cpp b/client_generic/Client/FirstTimeSetupVulkan.cpp new file mode 100644 index 00000000..a585697a --- /dev/null +++ b/client_generic/Client/FirstTimeSetupVulkan.cpp @@ -0,0 +1,1094 @@ +#if !defined(WIN32) && !defined(MAC) + +#include "FirstTimeSetupVulkan.h" +#include "ServerConfig.h" +#include "EDreamClient.h" +#include "PlatformUtils.h" +#include "PlatformUtils_Internal.h" +#include "Player.h" +#include "Settings.h" +#include "storage.h" + +#include +#include +#include +#include +#include +#include + +#include + +#ifdef HAVE_WAYLAND +#include +#endif + +typedef void (*ShowFirstTimeSetupCallback_t)(); +extern void ESSetShowFirstTimeSetupCallback(ShowFirstTimeSetupCallback_t); + +namespace { + +#ifdef STAGE +constexpr const char* kUrlCreateAccount = "https://stage.infinidream.ai/account"; +constexpr const char* kUrlWebRemote = "https://stage.infinidream.ai/rc"; +constexpr const char* kUrlPlaylists = "https://stage.infinidream.ai/playlists"; +#else +constexpr const char* kUrlCreateAccount = "https://alpha.infinidream.ai/account"; +constexpr const char* kUrlWebRemote = "https://alpha.infinidream.ai/rc"; +constexpr const char* kUrlPlaylists = "https://alpha.infinidream.ai/playlists"; +#endif + +// Narrower card — closer to the settings dialog footprint (S(541)×S(390)). +// Height is slightly larger than original to accommodate S(20) tips text. +constexpr float kDesignWinW = 700.f; +constexpr float kDesignWinH = 620.f; +constexpr float kLogoDisplay = 120.f; + +constexpr float kEmailStepPanelW = 571.f; +constexpr float kEmailStepPanelH = 268.f; +constexpr float kCodeStepPanelW = 509.f; +constexpr float kCodeStepPanelH = 330.f; +constexpr float kEmailPromptL = 45.f; +constexpr float kEmailMarginL = 47.f; +constexpr float kEmailFieldW = 364.f; +constexpr float kEmailSendBtnW = 120.f; +constexpr float kEmailCreateAccountW = 300.f; +constexpr float kEmailPromptOffsetY = 30.f; +constexpr float kEmailRowOffsetY = 115.f; +constexpr float kEmailErrorOffsetY = 151.f; +constexpr float kEmailCreateBtnOffsetY = 172.f; +constexpr float kCodeInstrOffsetY = 16.f; +constexpr float kCodeOtpOffsetY = 90.f; +// OTP field is S(36 font) + 2×S(10 padding) = S(56) tall; start + height + gap = 152. +constexpr float kCodeAfterOtpY = 152.f; +constexpr float kCodeVerifyW = 150.f; +constexpr float kCodeTryAgainW = 120.f; + +constexpr float kThanksPanelW = 660.f; +constexpr float kThanksOuterPad = 20.f; +constexpr float kThanksColGap = 20.f; +constexpr float kThanksTitleTop = 2.f; +constexpr float kThanksTitleToGrid = 8.f; +constexpr float kThanksRowTopH = 215.f; +constexpr float kThanksRowBotH = 132.f; +constexpr float kThanksHSepGap = 5.f; +constexpr float kThanksHSepH = 1.f; +constexpr float kThanksCellPad = 6.f; +constexpr float kThanksImgTop = 10.f; +constexpr float kThanksImgTrail = 6.f; +constexpr float kThanksTextImgGap = 12.f; +constexpr float kThanksPlaylistImg = 52.f; +constexpr float kThanksBtnBottom = 6.f; +constexpr float kThanksBottomPad = 4.f; +constexpr float kThanksOpenRemoteBtnW = 186.f; +constexpr float kThanksOpenPlaylistBtnW = 224.f; +constexpr float kThanksTipsBtnH = 28.f; +constexpr float kThanksDreamOnMinW = 82.f; +constexpr float kUiTextSize = 20.f; // design-space baseline; always used as S(kUiTextSize) +constexpr float kThanksCopyFontSize = kUiTextSize; +constexpr float kThanksBtnFontSize = kUiTextSize; + +static constexpr ImVec4 kMacInputBorderFocus(0.f, 0.478f, 1.f, 1.f); +static constexpr ImVec4 kMacInputBorderIdle(0.74f, 0.74f, 0.76f, 1.f); +static constexpr ImVec4 kMacAccentBtn(0.f, 0.478f, 1.f, 1.f); +static constexpr ImVec4 kMacAccentBtnHov(0.10f, 0.54f, 1.f, 1.f); +static constexpr ImVec4 kMacAccentBtnAct(0.f, 0.40f, 0.88f, 1.f); +static constexpr ImVec4 kMacAccentBtnBorder(0.f, 0.38f, 0.82f, 1.f); + +static float g_uiScale = 1.0f; // refreshed from PlatformUtils_GetUIScale() on each show +static inline float S(float v) { return v * g_uiScale; } + +std::atomic g_showRequested{false}; +std::atomic g_visible{false}; +std::atomic g_fontsLoaded{false}; +std::atomic g_wasPausedBeforeWizard{false}; +std::atomic g_pausedByWizard{false}; + +ImFont* g_fontBody = nullptr; +ImFont* g_fontButton = nullptr; +ImFont* g_fontTitle = nullptr; +ImFont* g_fontHeadline = nullptr; +ImFont* g_fontOtp = nullptr; +ImFont* g_fontThanksCopy = nullptr; +ImFont* g_fontThanksBtn = nullptr; + +std::mutex g_jobMutex; +std::atomic g_sendBusy{false}; +std::atomic g_sendDone{false}; +bool g_sendOk = false; +EDreamClient::SendCodeResult g_sendResult{false, 0, std::string()}; + +std::atomic g_validateBusy{false}; +std::atomic g_validateDone{false}; +bool g_validateOk = false; +EDreamClient::ValidateCodeResult g_validateResult{ + false, EDreamClient::ValidationFailureReason::None, 0, std::string()}; + +char g_emailBuf[256] = {}; +char g_codeBuf[16] = {}; +char g_errBuf[512] = {}; +int g_wizardStep = 0; + +bool g_emailFieldBorderActive = false; +bool g_otpFieldBorderActive = false; + +// --- Helpers ----------------------------------------------------------------- + +static bool IsLikelyEmail(const char* s) +{ + if (!s || !*s) return false; + const char* at = std::strchr(s, '@'); + if (!at || at == s) return false; + const char* dot = std::strchr(at + 1, '.'); + return dot != nullptr && dot > at + 1 && std::strlen(dot) > 1; +} + +static int FilterDigitsOnly(ImGuiInputTextCallbackData* data) +{ + constexpr int kMaxDigits = 6; + if (data->EventFlag == ImGuiInputTextFlags_CallbackCharFilter) + { + if (data->EventChar < '0' || data->EventChar > '9') return 1; + if (data->BufTextLen >= kMaxDigits) return 1; + return 0; + } + if (data->EventFlag == ImGuiInputTextFlags_CallbackEdit) + { + int w = 0; + for (int r = 0; r < data->BufTextLen && data->Buf[r] != '\0'; ++r) + { + const char c = data->Buf[r]; + if (c >= '0' && c <= '9') + { + data->Buf[w++] = c; + if (w >= kMaxDigits) break; + } + } + data->Buf[w] = '\0'; + data->BufTextLen = w; + data->BufDirty = true; + return 0; + } + return 0; +} + +static void StripNonDigits(char* buf, size_t bufSize) +{ + size_t w = 0; + for (size_t r = 0; buf[r] != '\0' && w + 1 < bufSize; ++r) + if (buf[r] >= '0' && buf[r] <= '9') buf[w++] = buf[r]; + buf[w] = '\0'; + if (w > 6) buf[6] = '\0'; +} + +struct AuthDialogContent { const char* title; std::string message; }; + +static AuthDialogContent BuildSendCodeFailureDialog(const EDreamClient::SendCodeResult& r) +{ + if (r.httpCode >= 400 && r.httpCode < 500) + { + std::string msg = "We couldn't send a verification email. Make sure your email address is correct, then try Send code again."; + if (!r.message.empty()) msg += "\n\n" + r.message; + return {"Unable to send code", msg}; + } + if (r.httpCode >= 500) + { + std::string msg = "Try again later."; + if (!r.message.empty()) msg += " " + r.message; + return {"Server Error", msg}; + } + return {"Authentication Error", + r.message.empty() ? "Failed to send verification code." : r.message}; +} + +static AuthDialogContent BuildValidateFailureDialog(const EDreamClient::ValidateCodeResult& r) +{ + if (r.httpCode >= 400 && r.httpCode < 500) + return {"Invalid Code", "Check for typos and check to be sure you have the most recent code. Try again or start over"}; + if (r.httpCode >= 500) + { + std::string msg = "Try again later."; + if (!r.message.empty()) msg += " " + r.message; + return {"Server Error", msg}; + } + return {"Authentication Error", + r.message.empty() ? "Backend is temporarily unavailable. Please try again shortly." : r.message}; +} + +// Inline error inside the wizard (no separate modal on Linux — just update g_errBuf). +static void ShowAuthWarning(const AuthDialogContent& content) +{ + std::snprintf(g_errBuf, sizeof g_errBuf, "%s", content.message.c_str()); +} + +// --- Font loading ------------------------------------------------------------ + +static std::string FindSystemFont(const char* name) +{ + // Search common Linux font directories in order of likelihood. + static const char* kDirs[] = { + "/usr/share/fonts/TTF/", + "/usr/share/fonts/truetype/", + "/usr/share/fonts/noto/", + "/usr/share/fonts/truetype/noto/", + "/usr/share/fonts/truetype/freefont/", + "/usr/share/fonts/truetype/liberation/", + nullptr, + }; + for (int i = 0; kDirs[i]; ++i) + { + std::string path = kDirs[i]; + path += name; + if (FILE* f = std::fopen(path.c_str(), "rb")) { std::fclose(f); return path; } + } + return {}; +} + +static void LoadWizardFonts() +{ + if (g_fontsLoaded.load(std::memory_order_acquire)) return; + + ImGuiIO& io = ImGui::GetIO(); + + // Try Noto Sans (common on modern Linux), then fall back to other sans-serif fonts. + static const char* kCandidates[] = { + "NotoSans-Regular.ttf", + "NotoSans[wdth,wght].ttf", + "DejaVuSans.ttf", + "LiberationSans-Regular.ttf", + "FreeSans.ttf", + nullptr, + }; + static const char* kBoldCandidates[] = { + "NotoSans-Bold.ttf", + "NotoSans[wdth,wght].ttf", + "DejaVuSans-Bold.ttf", + "LiberationSans-Bold.ttf", + "FreeSansBold.ttf", + nullptr, + }; + + std::string regular, bold; + for (int i = 0; kCandidates[i] && regular.empty(); ++i) + regular = FindSystemFont(kCandidates[i]); + for (int i = 0; kBoldCandidates[i] && bold.empty(); ++i) + bold = FindSystemFont(kBoldCandidates[i]); + + ImFontConfig cfg; + cfg.OversampleH = 2; + cfg.OversampleV = 1; + + if (!regular.empty()) + { + g_fontBody = io.Fonts->AddFontFromFileTTF(regular.c_str(), S(15.f), &cfg); + g_fontButton = io.Fonts->AddFontFromFileTTF(regular.c_str(), S(20.f), &cfg); + g_fontTitle = io.Fonts->AddFontFromFileTTF(regular.c_str(), S(28.f), &cfg); + g_fontOtp = io.Fonts->AddFontFromFileTTF(regular.c_str(), S(36.f), &cfg); + g_fontThanksCopy = io.Fonts->AddFontFromFileTTF(regular.c_str(), S(kThanksCopyFontSize), &cfg); + } + + const std::string& headlinePath = bold.empty() ? regular : bold; + if (!headlinePath.empty()) + { + g_fontHeadline = io.Fonts->AddFontFromFileTTF(headlinePath.c_str(), S(32.f), &cfg); + g_fontThanksBtn = io.Fonts->AddFontFromFileTTF(headlinePath.c_str(), S(kThanksBtnFontSize), &cfg); + } + + if (g_fontBody) io.FontDefault = g_fontBody; + + g_fontsLoaded.store(true, std::memory_order_release); +} + +// --- Style ------------------------------------------------------------------- + +static void ApplyLightSheetStyle() +{ + ImGuiStyle& s = ImGui::GetStyle(); + ImGui::StyleColorsLight(&s); + s.WindowRounding = 8.f; + s.ChildRounding = 6.f; + s.FrameRounding = 5.f; + s.PopupRounding = 6.f; + s.ScrollbarRounding = 8.f; + s.GrabRounding = 4.f; + s.WindowBorderSize = 1.f; + s.FrameBorderSize = 1.f; + s.WindowPadding = ImVec2(14.f, 14.f); + s.ItemSpacing = ImVec2(10.f, 8.f); + s.CellPadding = ImVec2(6.f, 4.f); + s.FramePadding = ImVec2(10.f, 8.f); + + ImVec4 text(0.10f, 0.11f, 0.13f, 1.f); + ImVec4 winBg(0.965f, 0.967f, 0.975f, 0.985f); + ImVec4 btn(1.f, 1.f, 1.f, 1.f); + ImVec4 btnHov(0.96f, 0.96f, 0.98f, 1.f); + ImVec4 btnAct(0.90f, 0.90f, 0.93f, 1.f); + ImVec4 borderChrome(0.74f, 0.74f, 0.76f, 1.f); + + s.Colors[ImGuiCol_Text] = text; + s.Colors[ImGuiCol_TextDisabled] = ImVec4(0.45f, 0.47f, 0.50f, 1.f); + s.Colors[ImGuiCol_WindowBg] = winBg; + s.Colors[ImGuiCol_ChildBg] = winBg; + s.Colors[ImGuiCol_Border] = borderChrome; + s.Colors[ImGuiCol_BorderShadow] = ImVec4(0.f, 0.f, 0.f, 0.f); + s.Colors[ImGuiCol_FrameBg] = ImVec4(1.f, 1.f, 1.f, 1.f); + s.Colors[ImGuiCol_FrameBgHovered] = ImVec4(0.98f, 0.98f, 0.99f, 1.f); + s.Colors[ImGuiCol_FrameBgActive] = ImVec4(1.f, 1.f, 1.f, 1.f); + s.Colors[ImGuiCol_Button] = btn; + s.Colors[ImGuiCol_ButtonHovered] = btnHov; + s.Colors[ImGuiCol_ButtonActive] = btnAct; + s.Colors[ImGuiCol_Header] = ImVec4(0.86f, 0.89f, 0.97f, 1.f); + s.Colors[ImGuiCol_ScrollbarBg] = ImVec4(0.93f, 0.94f, 0.96f, 0.85f); + s.Colors[ImGuiCol_NavHighlight] = ImVec4(0.f, 0.478f, 1.f, 0.55f); +} + +// --- Playback pause management ----------------------------------------------- + +static void ApplyWizardPauseState(bool visible) +{ + if (visible) + { + g_Player().SetFirstRunWizardPlaybackHold(true); + const bool wasPaused = g_Player().IsPaused(); + g_wasPausedBeforeWizard.store(wasPaused, std::memory_order_release); + if (!wasPaused) + { + g_Player().SetPaused(true, true); + g_pausedByWizard.store(true, std::memory_order_release); + } + else + g_pausedByWizard.store(false, std::memory_order_release); + return; + } + g_Player().SetFirstRunWizardPlaybackHold(false); + if (g_pausedByWizard.exchange(false, std::memory_order_acq_rel)) + g_Player().SetPaused(g_wasPausedBeforeWizard.load(std::memory_order_acquire), false); + g_Player().SetPausedForBuffering(false); +} + +// --- Wizard state reset ------------------------------------------------------ + +static void ResetWizardForShow() +{ + g_wizardStep = 0; + g_emailFieldBorderActive = false; + g_otpFieldBorderActive = false; + g_codeBuf[0] = '\0'; + g_errBuf[0] = '\0'; + std::string existing = g_Settings()->Get("settings.generator.nickname", std::string()); + std::strncpy(g_emailBuf, existing.c_str(), sizeof g_emailBuf - 1); + g_emailBuf[sizeof g_emailBuf - 1] = '\0'; +} + +// --- Async worker result polling --------------------------------------------- + +static void PollWorkerResults() +{ + if (g_sendDone.exchange(false)) + { + std::lock_guard lock(g_jobMutex); + if (g_sendOk) + { + g_wizardStep = 1; + g_errBuf[0] = '\0'; + } + else + { + const AuthDialogContent dlg = BuildSendCodeFailureDialog(g_sendResult); + ShowAuthWarning(dlg); + } + } + + if (g_validateDone.exchange(false)) + { + if (g_validateOk) + { + g_Settings()->Set("settings.app.firsttimesetup", true); + g_Settings()->Storage()->Commit(); + EDreamClient::DidSignIn(); + g_wizardStep = 2; + g_errBuf[0] = '\0'; + } + else + { + const AuthDialogContent dlg = BuildValidateFailureDialog(g_validateResult); + ShowAuthWarning(dlg); + g_codeBuf[0] = '\0'; + } + } +} + +// --- Drawing ----------------------------------------------------------------- + +static void DrawSkipFooter() +{ + if (g_wizardStep >= 2) return; + + const float skipW = S(80.f); + const float padY = ImGui::GetStyle().WindowPadding.y; + const float skipH = S(36.f); + float y = ImGui::GetWindowHeight() - padY - skipH; + if (y < 0.f) y = ImGui::GetCursorPosY(); + const float x = ImGui::GetCursorStartPos().x + ImGui::GetContentRegionAvail().x - skipW; + ImGui::SetCursorPos(ImVec2(x, y)); + + if (g_fontButton) ImGui::PushFont(g_fontButton); + if (ImGui::Button("Skip", ImVec2(skipW, skipH))) + { + g_visible.store(false, std::memory_order_release); + ApplyWizardPauseState(false); + } + if (g_fontButton) ImGui::PopFont(); +} + +static void DrawWizard() +{ + ImGuiIO& io = ImGui::GetIO(); + const ImVec2 display = io.DisplaySize; + const ImVec2 dialogPadding = ImGui::GetStyle().WindowPadding; + + const ImVec2 panelSize((std::min)(S(kDesignWinW), display.x), + (std::min)(S(kDesignWinH), display.y)); + float panelX = (display.x - panelSize.x) * 0.5f; + float panelY = (display.y - panelSize.y) * 0.5f; + panelX = (std::max)(0.f, panelX); + panelY = (std::max)(0.f, panelY); + + ImGui::SetNextWindowPos(ImVec2(0.f, 0.f), ImGuiCond_Always); + ImGui::SetNextWindowSize(display, ImGuiCond_Always); + ImGuiWindowFlags hostFlags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse; + + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.f, 0.f)); + ImVec4 sheetBg = ImGui::GetStyle().Colors[ImGuiCol_ChildBg]; + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.f, 0.f, 0.f, 0.35f)); + ImGui::Begin("##FirstTimeOverlayHost", nullptr, hostFlags); + + ImGui::SetCursorPos(ImVec2(panelX, panelY)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, dialogPadding); + ImGui::PushStyleColor(ImGuiCol_WindowBg, sheetBg); + ImGui::BeginChild("Welcome to Infinidream", panelSize, true, ImGuiWindowFlags_NoScrollbar); + + // Header + { + const float logoSz = S(kLogoDisplay); + const float hdrH = logoSz + S(11.f) + S(8.f); + const float winInnerW = ImGui::GetWindowWidth(); + + if (g_fontHeadline) ImGui::PushFont(g_fontHeadline); + const char* headline = "Welcome to Infinidream"; + const ImVec2 ts = ImGui::CalcTextSize(headline); + ImGui::SetCursorPos(ImVec2((winInnerW - ts.x) * 0.5f, S(50.f))); + ImGui::TextUnformatted(headline); + if (g_fontHeadline) ImGui::PopFont(); + + ImGui::SetCursorPos(ImVec2(0.f, hdrH)); + } + + const ImGuiStyle& sheetSt = ImGui::GetStyle(); + const float skipReserve = (g_wizardStep < 2) + ? (sheetSt.WindowPadding.y + S(36.f) + S(4.f)) + : (sheetSt.WindowPadding.y + S(8.f)); + float bodyH = ImGui::GetContentRegionAvail().y - skipReserve; + if (bodyH < 1.f) bodyH = 1.f; + + ImGuiWindowFlags bodyChildFlags = + ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse; + ImGui::BeginChild("body", ImVec2(0, bodyH), ImGuiChildFlags_None, bodyChildFlags); + + // ---- Step 0: Email ------------------------------------------------------- + if (g_wizardStep == 0) + { + g_otpFieldBorderActive = false; + const ImVec2 bodyAvail = ImGui::GetContentRegionAvail(); + ImGui::Dummy(ImVec2(0.f, S(20.f))); + float stepPadX = (bodyAvail.x - S(kEmailStepPanelW)) * 0.5f; + if (stepPadX > 0.f) ImGui::SetCursorPosX(ImGui::GetCursorPosX() + stepPadX); + ImGui::BeginChild("emailStep", ImVec2(S(kEmailStepPanelW), S(kEmailStepPanelH)), + ImGuiChildFlags_None, + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoBackground); + + ImGui::SetCursorPos(ImVec2(S(kEmailPromptL), S(kEmailPromptOffsetY))); + if (g_fontTitle) ImGui::PushFont(g_fontTitle); + ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + S(473.f)); + ImGui::TextUnformatted("Enter your email to sign in:"); + ImGui::PopTextWrapPos(); + if (g_fontTitle) ImGui::PopFont(); + + ImGui::SetCursorPos(ImVec2(S(kEmailMarginL), S(kEmailRowOffsetY))); + // y-padding chosen so height = S(20 font) + 2*S(8) = S(36), matching button height + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(S(10.f), S(8.f))); + if (g_fontButton) ImGui::PushFont(g_fontButton); + ImGui::PushItemWidth(S(kEmailFieldW)); + bool emailEnter = false; + { + ImGui::PushStyleColor(ImGuiCol_Border, + g_emailFieldBorderActive ? kMacInputBorderFocus : kMacInputBorderIdle); + emailEnter = ImGui::InputTextWithHint("##email", "your@email.com", g_emailBuf, sizeof g_emailBuf, + ImGuiInputTextFlags_EnterReturnsTrue); + ImGui::PopStyleColor(); + g_emailFieldBorderActive = ImGui::IsItemActive() || ImGui::IsItemFocused(); + } + ImGui::PopItemWidth(); + if (g_fontButton) ImGui::PopFont(); + ImGui::PopStyleVar(); + ImGui::SameLine(0.f, S(10.f)); + + const bool busy = g_sendBusy.load(std::memory_order_acquire); + const char* sendBtnText = busy ? "Sending..." : "Send code"; + const float sendBtnH = S(36.f); + ImVec2 sendBtnMin = ImGui::GetCursorScreenPos(); + + ImGui::PushStyleColor(ImGuiCol_Button, kMacAccentBtn); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, kMacAccentBtnHov); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, kMacAccentBtnAct); + ImGui::PushStyleColor(ImGuiCol_Border, kMacAccentBtnBorder); + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.f, 1.f, 1.f, 1.f)); + const bool clicked = ImGui::Button("##send_code_btn", ImVec2(S(kEmailSendBtnW), sendBtnH)); + ImGui::PopStyleColor(5); + + if (!busy && (clicked || emailEnter)) + { + const size_t elen = std::strlen(g_emailBuf); + if (elen == 0) + std::snprintf(g_errBuf, sizeof g_errBuf, "Please enter your email address"); + else if (!IsLikelyEmail(g_emailBuf)) + std::snprintf(g_errBuf, sizeof g_errBuf, "Please enter a valid email address"); + else + { + g_errBuf[0] = '\0'; + g_Settings()->Set("settings.generator.nickname", std::string(g_emailBuf)); + g_Settings()->Storage()->Commit(); + g_sendBusy.store(true, std::memory_order_release); + std::thread([]() { + EDreamClient::SendCodeResult result = EDreamClient::SendCode(); + { + std::lock_guard lock(g_jobMutex); + g_sendOk = result.success; + g_sendResult = std::move(result); + } + g_sendDone.store(true, std::memory_order_release); + g_sendBusy.store(false, std::memory_order_release); + }).detach(); + } + } + + if (g_errBuf[0] != '\0') + { + ImGui::SetCursorPos(ImVec2(S(kEmailPromptL), S(kEmailErrorOffsetY))); + if (g_fontButton) ImGui::PushFont(g_fontButton); + ImGui::TextColored(ImVec4(0.75f, 0.18f, 0.18f, 1.f), "%s", g_errBuf); + if (g_fontButton) ImGui::PopFont(); + } + // Draw button label centered (button uses "##" id to hide ImGui's label) + { + ImFont* btnFnt = g_fontButton ? g_fontButton : ImGui::GetFont(); + const float btnFntSz = g_fontButton ? S(20.f) : ImGui::GetFontSize(); + const ImVec2 ts = btnFnt->CalcTextSizeA(btnFntSz, FLT_MAX, 0.f, sendBtnText); + const ImVec2 textPos(sendBtnMin.x + (S(kEmailSendBtnW) - ts.x) * 0.5f, + sendBtnMin.y + (sendBtnH - ts.y) * 0.5f); + ImGui::GetWindowDrawList()->AddText( + btnFnt, btnFntSz, textPos, + ImGui::ColorConvertFloat4ToU32(ImVec4(1.f, 1.f, 1.f, 1.f)), sendBtnText); + } + + ImGui::SetCursorPos(ImVec2((S(kEmailStepPanelW) - S(kEmailCreateAccountW)) * 0.5f, + S(kEmailCreateBtnOffsetY))); + if (g_fontButton) ImGui::PushFont(g_fontButton); + if (ImGui::Button("Need an account? Create one", ImVec2(S(kEmailCreateAccountW), S(36.f)))) + PlatformUtils::OpenURLExternally(kUrlCreateAccount); + if (g_fontButton) ImGui::PopFont(); + + ImGui::EndChild(); // emailStep + } + // ---- Step 1: OTP -------------------------------------------------------- + else if (g_wizardStep == 1) + { + g_emailFieldBorderActive = false; + const ImVec2 bodyAvail = ImGui::GetContentRegionAvail(); + ImGui::Dummy(ImVec2(0.f, S(6.f))); + float stepPadX = (bodyAvail.x - S(kCodeStepPanelW)) * 0.5f; + if (stepPadX > 0.f) ImGui::SetCursorPosX(ImGui::GetCursorPosX() + stepPadX); + ImGui::BeginChild("codeStep", ImVec2(S(kCodeStepPanelW), S(kCodeStepPanelH)), + ImGuiChildFlags_None, + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoBackground); + + ImGui::SetCursorPos(ImVec2(S(38.f), S(kCodeInstrOffsetY))); + if (g_fontTitle) ImGui::PushFont(g_fontTitle); + ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + S(434.f)); + ImGui::TextUnformatted("Check your email for a one-time code, and enter it below."); + ImGui::PopTextWrapPos(); + if (g_fontTitle) ImGui::PopFont(); + + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(S(10.f), S(10.f))); + if (g_fontOtp) ImGui::PushFont(g_fontOtp); + + const float otpTextW = ImGui::CalcTextSize("000000").x; + const float otpInputW = otpTextW + ImGui::GetStyle().FramePadding.x * 2.f; + ImGui::SetCursorPos(ImVec2((S(kCodeStepPanelW) - otpInputW) * 0.5f, S(kCodeOtpOffsetY))); + ImGui::PushItemWidth(otpInputW); + bool otpEnter = false; + { + ImGui::PushStyleColor(ImGuiCol_Border, + g_otpFieldBorderActive ? kMacInputBorderFocus : kMacInputBorderIdle); + otpEnter = ImGui::InputTextWithHint("##otp", "000000", g_codeBuf, sizeof g_codeBuf, + ImGuiInputTextFlags_CallbackCharFilter | + ImGuiInputTextFlags_CallbackEdit | + ImGuiInputTextFlags_EnterReturnsTrue, + FilterDigitsOnly); + ImGui::PopStyleColor(); + g_otpFieldBorderActive = ImGui::IsItemActive() || ImGui::IsItemFocused(); + } + if (g_fontOtp) ImGui::PopFont(); + ImGui::PopStyleVar(); + ImGui::PopItemWidth(); + StripNonDigits(g_codeBuf, sizeof g_codeBuf); + + const bool busy = g_validateBusy.load(std::memory_order_acquire); + + // Flow layout below OTP: error (if any) pushes buttons down naturally. + ImGui::SetCursorPosY(S(kCodeAfterOtpY)); + if (g_errBuf[0] != '\0') + { + if (g_fontButton) ImGui::PushFont(g_fontButton); + ImGui::SetCursorPosX(S(18.f)); + ImGui::PushTextWrapPos(S(18.f) + S(473.f)); + ImGui::TextColored(ImVec4(0.75f, 0.18f, 0.18f, 1.f), "%s", g_errBuf); + ImGui::PopTextWrapPos(); + if (g_fontButton) ImGui::PopFont(); + ImGui::Dummy(ImVec2(0.f, S(12.f))); + } + else + { + ImGui::Dummy(ImVec2(0.f, S(20.f))); + } + + const bool canVerify = std::strlen(g_codeBuf) == 6 && !busy; + ImGui::SetCursorPosX((S(kCodeStepPanelW) - S(kCodeVerifyW)) * 0.5f); + if (!canVerify || busy) ImGui::BeginDisabled(); + if (g_fontButton) ImGui::PushFont(g_fontButton); + const bool verifyClicked = ImGui::Button("Verify code", ImVec2(S(kCodeVerifyW), S(36.f))); + if (g_fontButton) ImGui::PopFont(); + if (!canVerify || busy) ImGui::EndDisabled(); + + if (busy) + { + ImGui::SameLine(0.f, S(12.f)); + if (g_fontButton) ImGui::PushFont(g_fontButton); + ImGui::TextDisabled("Verifying..."); + if (g_fontButton) ImGui::PopFont(); + } + + if ((verifyClicked || otpEnter) && canVerify && !busy) + { + g_errBuf[0] = '\0'; + std::string codeCopy(g_codeBuf); + g_validateBusy.store(true, std::memory_order_release); + std::thread([code = std::move(codeCopy)]() { + EDreamClient::ValidateCodeResult result = EDreamClient::ValidateCodeDetailed(code); + g_validateOk = result.success; + g_validateResult = std::move(result); + g_validateDone.store(true, std::memory_order_release); + g_validateBusy.store(false, std::memory_order_release); + }).detach(); + } + + ImGui::Dummy(ImVec2(0.f, S(8.f))); + ImGui::SetCursorPosX((S(kCodeStepPanelW) - S(kCodeTryAgainW)) * 0.5f); + if (g_fontButton) ImGui::PushFont(g_fontButton); + if (ImGui::Button("Try again", ImVec2(S(kCodeTryAgainW), S(36.f)))) + { + g_wizardStep = 0; + g_codeBuf[0] = '\0'; + g_errBuf[0] = '\0'; + } + if (g_fontButton) ImGui::PopFont(); + + ImGui::EndChild(); // codeStep + } + // ---- Step 2: Thanks / tips ---------------------------------------------- + else + { + g_emailFieldBorderActive = false; + g_otpFieldBorderActive = false; + + const float bodyAvailW = ImGui::GetContentRegionAvail().x; + const float panelW = bodyAvailW < S(kThanksPanelW) ? bodyAvailW : S(kThanksPanelW); + const float centerPad = (bodyAvailW - panelW) * 0.5f; + if (centerPad > 0.f) ImGui::SetCursorPosX(ImGui::GetCursorPosX() + centerPad); + + const float innerW = panelW - 2.f * S(kThanksOuterPad); + const float colW = (innerW - S(kThanksColGap)) * 0.5f; + + if (g_fontTitle) ImGui::PushFont(g_fontTitle); + const char* tipsTitle = "All set! Quick tips:"; + const ImVec2 tipsTs = ImGui::CalcTextSize(tipsTitle); + if (g_fontTitle) ImGui::PopFont(); + + const float panelH = S(kThanksTitleTop) + tipsTs.y + S(kThanksTitleToGrid) + + S(kThanksRowTopH) + S(kThanksHSepGap) + S(kThanksHSepH) + + S(kThanksHSepGap) + S(kThanksRowBotH) + S(kThanksBottomPad); + + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.f, 0.f)); + ImGui::BeginChild("thanksPanel", ImVec2(panelW, panelH), ImGuiChildFlags_None, + ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollbar | + ImGuiWindowFlags_NoScrollWithMouse); + + ImGui::Dummy(ImVec2(0.f, S(kThanksTitleTop))); + if (g_fontTitle) ImGui::PushFont(g_fontTitle); + { + const float tipX = (panelW - tipsTs.x) * 0.5f; + if (tipX > 0.f) ImGui::SetCursorPosX(tipX); + ImGui::TextUnformatted(tipsTitle); + } + if (g_fontTitle) ImGui::PopFont(); + ImGui::Dummy(ImVec2(0.f, S(kThanksTitleToGrid))); + + const float yTop = ImGui::GetCursorPosY(); + ImDrawList* panelDl = ImGui::GetWindowDrawList(); + const ImU32 sepU32 = ImGui::ColorConvertFloat4ToU32(ImGui::GetStyle().Colors[ImGuiCol_Border]); + + // Top-left cell + ImGui::SetCursorPos(ImVec2(S(kThanksOuterPad), yTop)); + ImGui::BeginChild("tl", ImVec2(colW, S(kThanksRowTopH)), ImGuiChildFlags_None, + ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollbar | + ImGuiWindowFlags_NoScrollWithMouse); + { + if (g_fontThanksCopy) ImGui::PushFont(g_fontThanksCopy); + ImGui::SetCursorPos(ImVec2(S(kThanksCellPad), S(kThanksCellPad))); + ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + colW - 2.f * S(kThanksCellPad)); + ImGui::TextWrapped( + "Use the A and D keys to adjust the speed of playback. Press F1 to see more keyboard " + "controls. You can also interact with the remote control from a web browser:"); + ImGui::PopTextWrapPos(); + if (g_fontThanksCopy) ImGui::PopFont(); + + const float btnY = S(kThanksRowTopH) - S(kThanksBtnBottom) - S(kThanksTipsBtnH); + ImGui::SetCursorPos(ImVec2((colW - S(kThanksOpenRemoteBtnW)) * 0.5f, btnY)); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(S(8.f), S(3.f))); + if (g_fontThanksBtn) ImGui::PushFont(g_fontThanksBtn); + const bool openRemote = + ImGui::Button("Open web remote", ImVec2(S(kThanksOpenRemoteBtnW), S(kThanksTipsBtnH))); + if (g_fontThanksBtn) ImGui::PopFont(); + ImGui::PopStyleVar(); + if (openRemote) PlatformUtils::OpenURLExternally(kUrlWebRemote); + } + ImGui::EndChild(); + const ImVec2 tlRectMin = ImGui::GetItemRectMin(); + const ImVec2 tlRectMax = ImGui::GetItemRectMax(); + + // Top-right cell + ImGui::SetCursorPos(ImVec2(S(kThanksOuterPad) + colW + S(kThanksColGap), yTop)); + ImGui::BeginChild("tr", ImVec2(colW, S(kThanksRowTopH)), ImGuiChildFlags_None, + ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollbar | + ImGuiWindowFlags_NoScrollWithMouse); + { + if (g_fontThanksCopy) ImGui::PushFont(g_fontThanksCopy); + ImGui::SetCursorPos(ImVec2(S(kThanksCellPad), S(kThanksCellPad))); + ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + colW - 2.f * S(kThanksCellPad)); + ImGui::TextWrapped( + "Change your dreams by selecting a playlist from the browser. Click the button on a " + "thumbnail to start that playlist."); + ImGui::PopTextWrapPos(); + if (g_fontThanksCopy) ImGui::PopFont(); + + const float btnY = S(kThanksRowTopH) - S(kThanksBtnBottom) - S(kThanksTipsBtnH); + ImGui::SetCursorPos(ImVec2((colW - S(kThanksOpenPlaylistBtnW)) * 0.5f, btnY)); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(S(8.f), S(3.f))); + if (g_fontThanksBtn) ImGui::PushFont(g_fontThanksBtn); + const bool openPl = + ImGui::Button("Open playlist browser", ImVec2(S(kThanksOpenPlaylistBtnW), S(kThanksTipsBtnH))); + if (g_fontThanksBtn) ImGui::PopFont(); + ImGui::PopStyleVar(); + if (openPl) PlatformUtils::OpenURLExternally(kUrlPlaylists); + } + ImGui::EndChild(); + + // Vertical separator between top cells + const float vSepX = tlRectMax.x + S(10.f); + panelDl->AddRectFilled(ImVec2(vSepX, tlRectMin.y), + ImVec2(vSepX + 1.f, tlRectMin.y + S(kThanksRowTopH)), sepU32); + + // Horizontal separator + const float hSepTop = tlRectMax.y + S(kThanksHSepGap); + panelDl->AddRectFilled(ImVec2(tlRectMin.x, hSepTop), + ImVec2(tlRectMin.x + innerW, hSepTop + S(kThanksHSepH)), sepU32); + + const float yBot = yTop + S(kThanksRowTopH) + S(kThanksHSepGap) + S(kThanksHSepH) + S(kThanksHSepGap); + + // Bottom-left cell + ImGui::SetCursorPos(ImVec2(S(kThanksOuterPad), yBot)); + ImGui::BeginChild("bl", ImVec2(colW, S(kThanksRowBotH)), ImGuiChildFlags_None, + ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollbar | + ImGuiWindowFlags_NoScrollWithMouse); + { + static const char kBlTips[] = + "Press F to toggle fullscreen mode (or use the --fullscreen flag at launch). " + "Press Ctrl+R to open the remote control in a browser."; + const float wrapPx = colW - 2.f * S(kThanksCellPad); + if (g_fontThanksCopy) ImGui::PushFont(g_fontThanksCopy); + const ImVec2 wrapped = ImGui::CalcTextSize(kBlTips, nullptr, false, wrapPx); + if (g_fontThanksCopy) ImGui::PopFont(); + float yText = (S(kThanksRowBotH) - wrapped.y) * 0.5f; + if (yText < S(kThanksCellPad)) yText = S(kThanksCellPad); + ImGui::SetCursorPos(ImVec2(S(kThanksCellPad), yText)); + ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + wrapPx); + if (g_fontThanksCopy) ImGui::PushFont(g_fontThanksCopy); + ImGui::TextWrapped("%s", kBlTips); + if (g_fontThanksCopy) ImGui::PopFont(); + ImGui::PopTextWrapPos(); + } + ImGui::EndChild(); + const ImVec2 blRectMin = ImGui::GetItemRectMin(); + + // Bottom-right cell: Dream on! button + ImGui::SetCursorPos(ImVec2(S(kThanksOuterPad) + colW + S(kThanksColGap), yBot)); + ImGui::BeginChild("br", ImVec2(colW, S(kThanksRowBotH)), ImGuiChildFlags_None, + ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollbar | + ImGuiWindowFlags_NoScrollWithMouse); + { + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(S(8.f), S(3.f))); + if (g_fontThanksBtn) ImGui::PushFont(g_fontThanksBtn); + float dreamW = ImGui::CalcTextSize("Dream on!").x + ImGui::GetStyle().FramePadding.x * 2.f + + ImGui::GetStyle().FrameBorderSize * 2.f; + if (g_fontThanksBtn) ImGui::PopFont(); + ImGui::PopStyleVar(); + if (dreamW < S(kThanksDreamOnMinW)) dreamW = S(kThanksDreamOnMinW); + + const float bx = (colW - dreamW) * 0.5f; + const float by = (S(kThanksRowBotH) - S(kThanksTipsBtnH)) * 0.5f; + ImGui::SetCursorPos(ImVec2(bx, by)); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(S(8.f), S(3.f))); + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.f, 1.f, 1.f, 1.f)); + ImGui::PushStyleColor(ImGuiCol_Button, kMacAccentBtn); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, kMacAccentBtnHov); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, kMacAccentBtnAct); + ImGui::PushStyleColor(ImGuiCol_Border, kMacAccentBtnBorder); + if (g_fontThanksBtn) ImGui::PushFont(g_fontThanksBtn); + const bool dreamOn = ImGui::Button("Dream on!", ImVec2(dreamW, S(kThanksTipsBtnH))); + if (g_fontThanksBtn) ImGui::PopFont(); + ImGui::PopStyleColor(5); + ImGui::PopStyleVar(); + if (dreamOn) + { + g_visible.store(false, std::memory_order_release); + ApplyWizardPauseState(false); + } + } + ImGui::EndChild(); + + // Vertical separator between bottom cells + panelDl->AddRectFilled(ImVec2(vSepX, blRectMin.y), + ImVec2(vSepX + 1.f, blRectMin.y + S(kThanksRowBotH)), sepU32); + + ImGui::EndChild(); // thanksPanel + ImGui::PopStyleVar(); // WindowPadding 0,0 + } + + ImGui::EndChild(); // body + DrawSkipFooter(); + ImGui::EndChild(); // "Welcome to Infinidream" + ImGui::PopStyleColor(); // sheetBg + ImGui::PopStyleVar(); // dialog padding + ImGui::End(); + ImGui::PopStyleColor(); // host bg + ImGui::PopStyleVar(); // host padding +} + +// --- Clipboard (used by ImGui for Ctrl+V paste) ------------------------------ + +static const char* GetClipboardText(void*) +{ + static std::string s_clipboardBuf; + s_clipboardBuf.clear(); + + // Try wl-paste first (native Wayland), then fall back to X11 tools. + static const char* const kCmds[] = { + "wl-paste --no-newline 2>/dev/null", + "xclip -selection clipboard -o 2>/dev/null", + "xsel --clipboard --output 2>/dev/null", + nullptr, + }; + + for (int i = 0; kCmds[i]; ++i) + { + FILE* fp = popen(kCmds[i], "r"); + if (!fp) continue; + + char buf[256]; + while (fgets(buf, sizeof buf, fp)) + s_clipboardBuf += buf; + + pclose(fp); + + if (!s_clipboardBuf.empty()) + return s_clipboardBuf.c_str(); + } + + return s_clipboardBuf.c_str(); +} + +// --- Callback registered with EDreamClient ----------------------------------- + +static void OnFirstTimeSetupRequested() +{ + g_showRequested.store(true, std::memory_order_release); + g_Player().SetFirstRunWizardPlaybackHold(true); +} + +} // namespace + +// --- Public API -------------------------------------------------------------- + +void FirstTimeSetupVulkan_Register() +{ + ESSetShowFirstTimeSetupCallback(OnFirstTimeSetupRequested); +} + +bool FirstTimeSetupVulkan_IsWizardVisible() +{ + return g_visible.load(std::memory_order_acquire); +} + +void FirstTimeSetupVulkan_DrawIfNeeded() +{ + if (g_showRequested.load(std::memory_order_acquire)) + { + g_showRequested.store(false, std::memory_order_release); + + g_uiScale = PlatformUtils_GetUIScale(); + + // Load wizard fonts into the shared atlas on first show. + // ImGui_ImplVulkan_NewFrame() handles re-uploading the atlas automatically. + LoadWizardFonts(); + + // Save current style and apply wizard light-sheet style. + // (Restored in the same call via PushStyleVar/Color inside DrawWizard.) + ApplyLightSheetStyle(); + ImGui::GetStyle().ScaleAllSizes(g_uiScale); + + g_visible.store(true, std::memory_order_release); + ResetWizardForShow(); + ApplyWizardPauseState(true); + + // Enable keyboard navigation for text fields. + ImGui::GetIO().ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard; + + // Wire up clipboard so Ctrl+V paste works via wl-paste / xclip / xsel. + ImGui::GetIO().GetClipboardTextFn = GetClipboardText; + } + + if (!g_visible.load(std::memory_order_acquire)) return; + + PollWorkerResults(); + DrawWizard(); +} + +#ifdef HAVE_WAYLAND +bool FirstTimeSetupVulkan_FeedKey(uint32_t evdev_key, xkb_keysym_t keysym, bool pressed, + struct xkb_state* state) +{ + if (!g_visible.load(std::memory_order_acquire)) return false; + + ImGuiIO& io = ImGui::GetIO(); + + // Map xkb keysyms to ImGui keys for navigation and editing. + ImGuiKey imkey = ImGuiKey_None; + switch (keysym) + { + case XKB_KEY_BackSpace: imkey = ImGuiKey_Backspace; break; + case XKB_KEY_Delete: imkey = ImGuiKey_Delete; break; + case XKB_KEY_Return: + case XKB_KEY_KP_Enter: imkey = ImGuiKey_Enter; break; + case XKB_KEY_Tab: imkey = ImGuiKey_Tab; break; + case XKB_KEY_Left: imkey = ImGuiKey_LeftArrow; break; + case XKB_KEY_Right: imkey = ImGuiKey_RightArrow; break; + case XKB_KEY_Home: imkey = ImGuiKey_Home; break; + case XKB_KEY_End: imkey = ImGuiKey_End; break; + case XKB_KEY_Insert: imkey = ImGuiKey_Insert; break; + case XKB_KEY_Escape: imkey = ImGuiKey_Escape; break; + default: break; + } + + bool ctrl = false; + bool shift = false; + if (state) + { + ctrl = xkb_state_mod_name_is_active(state, XKB_MOD_NAME_CTRL, + XKB_STATE_MODS_EFFECTIVE) > 0; + shift = xkb_state_mod_name_is_active(state, XKB_MOD_NAME_SHIFT, + XKB_STATE_MODS_EFFECTIVE) > 0; + } + + if (ctrl) + { + switch (keysym) + { + case XKB_KEY_a: imkey = ImGuiKey_A; break; + case XKB_KEY_c: imkey = ImGuiKey_C; break; + case XKB_KEY_v: imkey = ImGuiKey_V; break; + case XKB_KEY_x: imkey = ImGuiKey_X; break; + case XKB_KEY_z: imkey = ImGuiKey_Z; break; + default: break; + } + } + + // Shift+Insert is the classic alternate paste shortcut — route it through + // ImGui's normal Ctrl+V paste path so GetClipboardTextFn is called. + if (shift && keysym == XKB_KEY_Insert) + { + io.AddKeyEvent(ImGuiMod_Ctrl, true); + io.AddKeyEvent(ImGuiKey_V, pressed); + io.AddKeyEvent(ImGuiMod_Ctrl, false); + io.AddKeyEvent(ImGuiMod_Shift, false); + return true; + } + + if (imkey != ImGuiKey_None) + io.AddKeyEvent(imkey, pressed); + + io.AddKeyEvent(ImGuiMod_Ctrl, ctrl); + + // Feed printable characters using xkb_state_key_get_utf8 for full layout support + // (handles non-ASCII characters correctly for any active keyboard layout). + if (pressed && state && !ctrl && !(shift && keysym == XKB_KEY_Insert)) + { + const xkb_keycode_t xkb_keycode = evdev_key + 8; + + // One Unicode codepoint is at most 4 bytes in UTF-8 + null terminator = 5; + // 8 gives comfortable headroom for the xkb_state_key_get_utf8 output buffer. + char utf8charbuf[8] = {}; + int len = xkb_state_key_get_utf8(state, xkb_keycode, utf8charbuf, sizeof utf8charbuf); + + // Exclude control characters (< 0x20) and DEL (0x7F). + if (len > 0 && + static_cast(utf8charbuf[0]) >= 0x20 && + static_cast(utf8charbuf[0]) != 0x7f) + { + io.AddInputCharactersUTF8(utf8charbuf); + } + } + + return true; // always consume while wizard is visible +} +#endif // HAVE_WAYLAND + +void FirstTimeSetupVulkan_FeedMousePos(int x, int y) +{ + if (!g_visible.load(std::memory_order_acquire)) return; + ImGui::GetIO().AddMousePosEvent(static_cast(x), static_cast(y)); +} + +void FirstTimeSetupVulkan_FeedMouseButton(uint32_t button, bool pressed) +{ + if (!g_visible.load(std::memory_order_acquire)) return; + // Wayland BTN_LEFT=272, BTN_RIGHT=273, BTN_MIDDLE=274 + int imguiBtn = -1; + if (button == 272) imguiBtn = 0; // left + else if (button == 273) imguiBtn = 1; // right + else if (button == 274) imguiBtn = 2; // middle + if (imguiBtn >= 0) + ImGui::GetIO().AddMouseButtonEvent(imguiBtn, pressed); +} + +#endif // !WIN32 && !MAC diff --git a/client_generic/Client/FirstTimeSetupVulkan.h b/client_generic/Client/FirstTimeSetupVulkan.h new file mode 100644 index 00000000..3029c86c --- /dev/null +++ b/client_generic/Client/FirstTimeSetupVulkan.h @@ -0,0 +1,35 @@ +#pragma once + +#if !defined(WIN32) && !defined(MAC) + +#include + +#ifdef HAVE_WAYLAND +#include +#endif + +/// Register the ESSetShowFirstTimeSetupCallback so the auth layer can trigger the wizard. +void FirstTimeSetupVulkan_Register(); + +/// True while the wizard overlay is visible. Used to block game key-bindings. +bool FirstTimeSetupVulkan_IsWizardVisible(); + +/// Called from RendererVulkan::EndFrame() just before ImGui::Render() — emits wizard ImGui +/// calls into the current frame if the wizard is visible. +void FirstTimeSetupVulkan_DrawIfNeeded(); + +/// Feed a Wayland keyboard event into ImGui. `evdev_key` is the raw Wayland key code (before +/// the +8 xkb offset). Returns true if the wizard consumed the event (caller should skip +/// pushing it onto the normal event queue). +#ifdef HAVE_WAYLAND +bool FirstTimeSetupVulkan_FeedKey(uint32_t evdev_key, xkb_keysym_t keysym, bool pressed, + struct xkb_state* state); +#endif + +/// Feed pointer (mouse) position from Wayland callbacks. +void FirstTimeSetupVulkan_FeedMousePos(int x, int y); + +/// Feed pointer button from Wayland callbacks (button: 272 = left BTN_LEFT, 273 = right). +void FirstTimeSetupVulkan_FeedMouseButton(uint32_t button, bool pressed); + +#endif // !WIN32 && !MAC diff --git a/client_generic/Client/Player.cpp b/client_generic/Client/Player.cpp index 4d77b7ef..bd771947 100644 --- a/client_generic/Client/Player.cpp +++ b/client_generic/Client/Player.cpp @@ -100,8 +100,16 @@ double ClampPerceptualFPS(double fps) // Async destruction of a clip. The destructor may be delayed by a // catch up streaming mechanism for caching void destroyClipAsync(ContentDecoder::spCClip clip) { + if (!clip) + return; + + // Render resources must never be destroyed by the detached decoder cleanup + // thread: Vulkan command pools and resource lifetimes require external + // synchronization with rendering. + clip->ReleaseRenderResources(); std::thread([clip = std::move(clip)]() mutable { - // This should call the destructor now + // Decoder shutdown can block while its worker exits, but all render-owned + // resources have already been released safely on the player thread. clip = nullptr; }).detach(); } @@ -128,9 +136,184 @@ CPlayer::CPlayer() : m_isFirstPlay(true), m_offlineMode(false), m_pendingSeekCro m_playlistManager = std::make_unique(); } +void CPlayer::EnqueuePlayDream(std::string uuid, int64_t frameNumber) +{ + std::scoped_lock lock(m_pendingCommandMutex); + m_pendingCommands.push_back( + {PendingCommandType::PlayDream, std::move(uuid), frameNumber, nullptr, + {}, false, {}, false}); +} + +void CPlayer::EnqueuePlaylistChange(std::string uuid) +{ + std::scoped_lock lock(m_pendingCommandMutex); + m_pendingCommands.push_back( + {PendingCommandType::ChangePlaylist, std::move(uuid), -1, nullptr, + {}, false, {}, false}); +} + +void CPlayer::EnqueuePlaylistInitialization(std::string playlistUuid, + std::string resumeDreamUuid, + bool offline) +{ + std::scoped_lock lock(m_pendingCommandMutex); + m_pendingCommands.push_back( + {PendingCommandType::InitializePlaylist, std::move(playlistUuid), -1, + nullptr, {}, false, std::move(resumeDreamUuid), offline}); +} + +void CPlayer::EnqueuePreparedDream(std::shared_ptr dream, + std::string streamingPath, int64_t frameNumber) +{ + if (!dream || m_shutdownFlag.load()) + return; + + std::scoped_lock lock(m_pendingCommandMutex); + m_pendingCommands.push_back( + {PendingCommandType::PlayPreparedDream, dream->uuid, frameNumber, + std::move(dream), std::move(streamingPath), false, {}, false}); +} + +void CPlayer::EnqueuePreparedPreload(std::shared_ptr dream, + std::string streamingPath, bool seamless) +{ + if (!dream || m_shutdownFlag.load()) + return; + + std::scoped_lock lock(m_pendingCommandMutex); + m_pendingCommands.push_back( + {PendingCommandType::PreloadPreparedDream, dream->uuid, -1, + std::move(dream), std::move(streamingPath), seamless, {}, false}); +} + +void CPlayer::ReapBackgroundTasks() +{ + std::scoped_lock lock(m_backgroundTaskMutex); + auto it = m_backgroundTasks.begin(); + while (it != m_backgroundTasks.end()) + { + if (it->wait_for(std::chrono::seconds(0)) == std::future_status::ready) + { + try + { + it->get(); + } + catch (const std::exception& e) + { + g_Log->Error("Background player task failed: %s", e.what()); + } + it = m_backgroundTasks.erase(it); + } + else + { + ++it; + } + } +} + +void CPlayer::WaitForBackgroundTasks() +{ + std::vector> tasks; + { + std::scoped_lock lock(m_backgroundTaskMutex); + tasks.swap(m_backgroundTasks); + } + + for (auto& task : tasks) + { + if (!task.valid()) + continue; + try + { + task.get(); + } + catch (const std::exception& e) + { + g_Log->Error("Background player task failed during shutdown: %s", e.what()); + } + } +} + +void CPlayer::ProcessPendingCommands() +{ + ReapBackgroundTasks(); + + std::deque commands; + { + std::scoped_lock lock(m_pendingCommandMutex); + commands.swap(m_pendingCommands); + } + + if (commands.empty()) + return; + + if (m_updateThreadId == std::thread::id{}) + m_updateThreadId = std::this_thread::get_id(); + + for (auto& command : commands) + { + switch (command.type) + { + case PendingCommandType::ChangePlaylist: + { + writer_lock lock(m_UpdateMutex); + g_Log->Info("Applying queued playlist change on the player thread: %s", + command.uuid.c_str()); + if (SetPlaylist(command.uuid, false)) + { + SetTransitionDuration(1.0f); + StartTransition(); + } + break; + } + case PendingCommandType::PlayDream: + g_Log->Info("Applying queued dream change on the player thread: %s", + command.uuid.c_str()); + PlayDreamNow(command.uuid, command.frameNumber); + break; + case PendingCommandType::PlayPreparedDream: + g_Log->Info("Applying prepared dream on the player thread: %s", + command.uuid.c_str()); + if (command.dream && !command.streamingPath.empty()) + command.dream->setStreamingUrl(command.streamingPath); + ApplyPreparedDream(command.dream, command.frameNumber); + break; + case PendingCommandType::PreloadPreparedDream: + ApplyPreparedPreload(command.dream, command.streamingPath, + command.seamless); + break; + case PendingCommandType::InitializePlaylist: + { + writer_lock lock(m_UpdateMutex); + g_Log->Info("Initializing %s playlist on the player thread: %s", + command.offline ? "offline" : "online", + command.uuid.c_str()); + SetOfflineMode(command.offline); + if (m_currentClip) + { + destroyClipAsync(std::move(m_currentClip)); + m_currentClip = nullptr; + } + if (m_nextClip) + { + destroyClipAsync(std::move(m_nextClip)); + m_nextClip = nullptr; + } + + bool initialized = command.resumeDreamUuid.empty() + ? SetPlaylist(command.uuid, false) + : SetPlaylistAtDream(command.uuid, command.resumeDreamUuid, false); + if (initialized) + m_hasStarted = true; + break; + } + } + } +} + void CPlayer::SetOfflineMode(bool offline) { - m_offlineMode = offline; + m_offlineMode.store(offline); if (m_playlistManager) { m_playlistManager->setOfflineMode(offline); @@ -140,7 +323,7 @@ void CPlayer::SetOfflineMode(bool offline) bool CPlayer::IsOfflineMode() const { - return m_offlineMode; + return m_offlineMode.load(); } /* @@ -389,8 +572,6 @@ void CPlayer::BootstrapLoggedInPlaylist() if (shouldAbort()) return; - SetOfflineMode(false); - if (!EDreamClient::fIsWebSocketConnected.load()) { g_Log->Info("Player bootstrap logged-in: connecting websocket"); boost::thread webSocketThread(&EDreamClient::ConnectRemoteControlSocket); @@ -413,27 +594,22 @@ void CPlayer::BootstrapLoggedInPlaylist() lastPlayedUUID = ""; } - m_currentClip = nullptr; - if (shouldAbort()) return; - if (lastPlayedUUID.empty()) { - SetPlaylist(serverPlaylistId, false); - } else { - SetPlaylistAtDream(serverPlaylistId, lastPlayedUUID, false); - } + EnqueuePlaylistInitialization(serverPlaylistId, lastPlayedUUID, false); } void CPlayer::EnsureOnlinePlaybackAfterSignIn() { - std::thread([this]() { + std::scoped_lock taskLock(m_backgroundTaskMutex); + m_backgroundTasks.emplace_back(std::async(std::launch::async, [this]() { if (m_shutdownFlag.load() || g_NetworkManager->IsAborted()) return; if (!EDreamClient::IsLoggedIn()) return; g_Log->Info("Sign-in after offline startup: loading server playlist and quota"); BootstrapLoggedInPlaylist(); - }).detach(); + })); } void CPlayer::Start() @@ -482,29 +658,16 @@ void CPlayer::Start() if (EDreamClient::IsLoggedIn()) { if (shouldAbort()) return; BootstrapLoggedInPlaylist(); - if (!shouldAbort()) { - m_hasStarted = true; - } } else { if (shouldAbort()) return; // Not logged in (including transient auth/server outage): play from cache only. // This avoids blocking startup on server calls for streaming links/metadata. - SetOfflineMode(true); - - m_currentClip = nullptr; - if (EDreamClient::IsLoggedIn()) { // Signed in while this thread was preparing the offline playlist. BootstrapLoggedInPlaylist(); - } else if (lastPlayedUUID.empty()) { - SetPlaylist(clientPlaylistId, false); } else { - SetPlaylistAtDream(clientPlaylistId, lastPlayedUUID, false); - } - - if (!shouldAbort()) { - m_hasStarted = true; + EnqueuePlaylistInitialization(clientPlaylistId, lastPlayedUUID, true); } } }); @@ -554,33 +717,54 @@ bool CPlayer::Shutdown(void) g_Log->Info("CPlayer::Shutdown()\n"); Stop(); - - // Signal any current clips to abort immediately - if (m_currentClip) { - writer_lock l(m_UpdateMutex); - if (m_currentClip->GetClipMetadata().path.substr(0, 4) == "http") { - // Get the decoder and signal shutdown - m_currentClip->GetDecoder()->signalShutdown(); + + m_shutdownFlag = true; + WaitForBackgroundTasks(); + + // Release clip-owned Vulkan resources before dropping the displays and + // renderer. Decoder teardown may continue asynchronously after its render + // references have been removed. + { + writer_lock lock(m_UpdateMutex); + if (m_currentClip) + { + if (m_currentClip->GetClipMetadata().path.substr(0, 4) == "http") + m_currentClip->GetDecoder()->signalShutdown(); + destroyClipAsync(std::move(m_currentClip)); + m_currentClip = nullptr; } + if (m_nextClip) + { + if (m_nextClip->GetClipMetadata().path.substr(0, 4) == "http") + m_nextClip->GetDecoder()->signalShutdown(); + destroyClipAsync(std::move(m_nextClip)); + m_nextClip = nullptr; + } + m_nextDreamDecision = std::nullopt; + m_isTransitioning = false; + m_PreloadingNextClip = false; + m_PreloadingDreamUUID.clear(); } - if (m_nextClip) { - writer_lock l(m_UpdateMutex); - if (m_nextClip->GetClipMetadata().path.substr(0, 4) == "http") { - m_nextClip->GetDecoder()->signalShutdown(); - } + { + std::scoped_lock lock(m_pendingCommandMutex); + m_pendingCommands.clear(); } - m_displayUnits.clear(); + { + std::scoped_lock lock(m_displayListMutex); + m_displayUnits.clear(); + } m_bStarted = false; - m_shutdownFlag = true; return true; } CPlayer::~CPlayer() { + m_shutdownFlag = true; + WaitForBackgroundTasks(); m_playlistManager = nullptr; // Mark singleton as properly shutdown, to track unwanted access after this // point. @@ -701,6 +885,9 @@ bool CPlayer::Update(uint32_t displayUnit) du = m_displayUnits[displayUnit]; } + if (displayUnit == 0) + ProcessPendingCommands(); + du->spRenderer->Reset(eEverything); du->spRenderer->Orthographic(); du->spRenderer->Apply(); @@ -799,7 +986,13 @@ bool CPlayer::Update(uint32_t displayUnit) m_nextDreamDecision->transition == PlaylistManager::TransitionType::Seamless; if (!m_nextClip->HasFinished() && !waitingForSeamless) { - m_nextClip->Update(m_TimelineTime, freezePlayback); + // Don't consume frames from m_nextClip until the crossfade has started — + // same reasoning as the seamless guard above. The decoder fills the queue + // to the backpressure cap; playback then starts from near frame 0 rather + // than mid-stream after the preload period drains the buffer. + if (m_nextClip->IsBuffering() || m_isTransitioning) { + m_nextClip->Update(m_TimelineTime, freezePlayback); + } } // Check if pending seek crossfade can now start (next clip finished buffering) @@ -1111,139 +1304,67 @@ double CPlayer::GetDecoderFPS() { } void CPlayer::PlayDreamNow(std::string_view _uuid, int64_t frameNumber) { - // Reset any pending transition decision - m_nextDreamDecision = std::nullopt; - Cache::CacheManager& cm = Cache::CacheManager::getInstance(); - // NOTE : This is the only path that currently streams - if (cm.hasDream(std::string(_uuid))) { - auto dream = cm.getDream(std::string(_uuid)); + const std::string uuid(_uuid); + auto dream = cm.hasDream(uuid) ? cm.getDream(uuid) : nullptr; - if (dream->isCached()) { - writer_lock l(m_UpdateMutex); + if (dream && dream->isCached()) + { + ApplyPreparedDream(dream, frameNumber); + return; + } - // Check if the dream is in the current playlist and update position - m_playlistManager->getDreamByUUID(std::string(_uuid)); - - // Cancel any ongoing transition/preload - if (m_isTransitioning && m_nextClip) { - destroyClipAsync(std::move(m_nextClip)); - m_nextClip = nullptr; + std::scoped_lock taskLock(m_backgroundTaskMutex); + m_backgroundTasks.emplace_back(std::async(std::launch::async, + [this, uuid, frameNumber, dream]() mutable { + auto preparedDream = std::move(dream); + if (!preparedDream) + { + EDreamClient::FetchDreamMetadata(uuid); + Cache::CacheManager::getInstance().reloadMetadata(uuid); + preparedDream = Cache::CacheManager::getInstance().getDream(uuid); } - m_isTransitioning = false; - m_nextDreamDecision = std::nullopt; - m_PreloadingNextClip = false; - m_PreloadingDreamUUID = ""; - - // Set up transition parameters (but don't start yet - wait for clip to buffer) - m_transitionDuration = 1.0f; - m_pendingSeekCrossfade = true; // Will start transition when next clip is ready - - // Create the new clip at the target position (it will start buffering) - PlayClip(dream, m_TimelineTime, frameNumber, true); - if (m_nextClip) { - m_nextClip->SetTransitionLength(1.0f, 5.0f); - } - } else { - std::thread([this, frameNumber, dream = dream]() { - // Fetch URL first - auto path = EDreamClient::GetDreamDownloadLink(dream->uuid); - dream->setStreamingUrl(path); - - // Check if the dream is in the current playlist and update position - m_playlistManager->getDreamByUUID(dream->uuid); - - // Prepare the clip outside the lock - if (m_displayUnits.empty()) { - g_Log->Error("Cannot play clip: no display units available"); - return false; - } - auto du = m_displayUnits[0]; - int32_t displayMode = g_Settings()->Get("settings.player.DisplayMode", 2); - - auto newClip = std::make_shared( - ContentDecoder::sClipMetadata{path, m_PerceptualFPS / dream->activityLevel, *dream}, - du->spRenderer, displayMode, du->spDisplay->Width(), - du->spDisplay->Height()); - - // Start the clip before taking the lock, this replaces PlayClip - if (newClip->Start(frameNumber)) { - // Only take the lock once everything is ready - writer_lock l(m_UpdateMutex); - - // Cancel any ongoing transition/preload - if (m_isTransitioning && m_nextClip) { - destroyClipAsync(std::move(m_nextClip)); - m_nextClip = nullptr; - } - m_isTransitioning = false; - m_nextDreamDecision = std::nullopt; - m_PreloadingNextClip = false; - m_PreloadingDreamUUID = ""; - - // Set up transition parameters (but don't start yet - wait for clip to buffer) - m_transitionDuration = 1.0f; - m_pendingSeekCrossfade = true; // Will start transition when next clip is ready - - // Set the start time and store the clip - newClip->SetStartTime(m_TimelineTime); - m_nextClip = newClip; - if (m_nextClip) { - m_nextClip->SetTransitionLength(1.0f, 5.0f); - } - } - return true; - }).detach(); - } - } else { - std::thread([uuid = std::string(_uuid), &cm, this, frameNumber]() { - EDreamClient::FetchDreamMetadata(uuid); - cm.reloadMetadata(uuid); - - auto dream = cm.getDream(uuid); - if (!dream) { + if (!preparedDream) + { g_Log->Error("Can't get dream metadata, aborting PlayDreamNow"); return; } - - auto path = EDreamClient::GetDreamDownloadLink(dream->uuid); - dream->setStreamingUrl(path); - - // Prepare clip outside lock, this replaces PlayClip - auto du = m_displayUnits[0]; - int32_t displayMode = g_Settings()->Get("settings.player.DisplayMode", 2); - - auto newClip = std::make_shared( - ContentDecoder::sClipMetadata{path, m_PerceptualFPS / dream->activityLevel, *dream}, - du->spRenderer, displayMode, du->spDisplay->Width(), - du->spDisplay->Height()); - - if (newClip->Start(frameNumber)) { - writer_lock l(m_UpdateMutex); - - // Cancel any ongoing transition/preload - if (m_isTransitioning && m_nextClip) { - destroyClipAsync(std::move(m_nextClip)); - m_nextClip = nullptr; - } - m_isTransitioning = false; - m_nextDreamDecision = std::nullopt; - m_PreloadingNextClip = false; - m_PreloadingDreamUUID = ""; - - // Set up transition parameters (but don't start yet - wait for clip to buffer) - m_transitionDuration = 1.0f; - m_pendingSeekCrossfade = true; // Will start transition when next clip is ready - - newClip->SetStartTime(m_TimelineTime); - m_nextClip = newClip; - if (m_nextClip) { - m_nextClip->SetTransitionLength(1.0f, 5.0f); - } - } - }).detach(); + + const auto path = EDreamClient::GetDreamDownloadLink(preparedDream->uuid); + if (path.empty() || m_shutdownFlag.load()) + return; + + EnqueuePreparedDream(std::move(preparedDream), path, frameNumber); + })); +} + +void CPlayer::ApplyPreparedDream( + const std::shared_ptr& dream, int64_t frameNumber) +{ + if (!dream) + return; + + writer_lock lock(m_UpdateMutex); + m_nextDreamDecision = std::nullopt; + + // Keep playlist position coherent when the selected dream belongs to it. + m_playlistManager->getDreamByUUID(dream->uuid); + + if (m_nextClip) + { + destroyClipAsync(std::move(m_nextClip)); + m_nextClip = nullptr; } + + m_isTransitioning = false; + m_PreloadingNextClip = false; + m_PreloadingDreamUUID.clear(); + m_transitionDuration = 1.0f; + m_pendingSeekCrossfade = true; + + if (PlayClip(dream, m_TimelineTime, frameNumber, true) && m_nextClip) + m_nextClip->SetTransitionLength(1.0f, 5.0f); } std::string CPlayer::GetPlaylistName() const @@ -1297,12 +1418,18 @@ bool CPlayer::SetPlaylist(const std::string& playlistUUID, bool fetchPlaylist = // Use a local copy to avoid racing with other threads that may reset m_nextDreamDecision. if (nextDecision) { g_Log->Info("Preloading next clip for playlist switch"); - PlayClip(nextDecision->dream, m_TimelineTime, -1, true); + if (m_nextClip) { - m_nextClip->SetTransitionLength(1.0f, 5.0f); + destroyClipAsync(std::move(m_nextClip)); + m_nextClip = nullptr; } - m_playlistManager->moveToNextDream(*nextDecision); + m_PreloadingNextClip = false; + m_PreloadingDreamUUID.clear(); + m_nextDreamDecision = nextDecision; + RequestPreloadClip(nextDecision->dream, false); + if (m_nextClip) + m_nextClip->SetTransitionLength(1.0f, 5.0f); } } @@ -1375,19 +1502,15 @@ bool CPlayer::SetPlaylistAtDream(const std::string& playlistUUID, const std::str void CPlayer::ResetPlaylist() { - // Reset any pending transition decision + writer_lock lock(m_UpdateMutex); m_nextDreamDecision = std::nullopt; - //writer_lock l(m_UpdateMutex); - - // Grab the default playlist again & set it - g_Log->Info("PreReset"); - std::thread([this]{ - SetPlaylist(""); + g_Log->Info("Resetting playlist on the player thread"); + if (SetPlaylist("")) + { SetTransitionDuration(1.0f); StartTransition(); - }).detach(); - g_Log->Info("PostReset"); + } } // MARK: - Transition @@ -1432,15 +1555,75 @@ void CPlayer::UpdateTransition(double currentTime) { if (!m_isTransitioning) return; + // A transition can be requested before an asynchronous preload completes. + // Never let its timer retire the outgoing clip until a replacement exists. + // If preparation failed, cancel the transition and keep the current clip + // visible. + if (!m_nextClip) + { + if (m_PreloadingNextClip) + { + m_transitionStartTime = currentTime; + return; + } + + g_Log->Warning("Cancelling transition because no replacement clip is available"); + m_isTransitioning = false; + m_pendingSeekCrossfade = false; + m_nextDreamDecision = std::nullopt; + return; + } + + if (m_nextClip->IsPreloadFailed()) + { + g_Log->Warning("Cancelling transition because replacement decoder failed to open"); + destroyClipAsync(std::move(m_nextClip)); + m_nextClip = nullptr; + m_isTransitioning = false; + m_pendingSeekCrossfade = false; + m_PreloadingNextClip = false; + m_PreloadingDreamUUID.clear(); + m_nextDreamDecision = std::nullopt; + return; + } + + // Opening the decoder is not enough to begin a transition. Wait until it + // has buffered frames, then start its playback clock and anchor it to the + // current timeline before allowing the crossfade timer to advance. + if (!m_nextClip->HasStartedPlaying()) + { + if (!m_nextClip->IsPreloadComplete()) + { + m_transitionStartTime = currentTime; + return; + } + + m_nextClip->SetStartTime(currentTime); + m_nextClip->ResetFinished(); + if (!m_nextClip->StartPlayback(0)) + { + g_Log->Warning("Cancelling transition because replacement playback could not start"); + m_isTransitioning = false; + m_nextDreamDecision = std::nullopt; + return; + } + m_transitionStartTime = currentTime; + } + double transitionProgress = (currentTime - m_transitionStartTime) / m_transitionDuration; bool nextClipBuffering = (m_nextClip && m_nextClip->IsBuffering()); - - /*if (nextClipBuffering) { - g_Log->Info("Next clip still buffering during transition (progress: %.2f)", - transitionProgress); - }*/ - + + // Keep the outgoing clip visible until the incoming clip can draw. Snapping + // to a buffering replacement produces a persistent black frame. + if (nextClipBuffering) { + m_transitionStartTime = currentTime; + return; + } + if (m_currentClip && m_currentClip->HasFinished()) { + transitionProgress = 1.0; + } + // If we have preflight decision and it's seamless, but we're transitioning, // that means it was interrupted - convert to quick fade if (m_nextDreamDecision && @@ -1983,50 +2166,8 @@ void CPlayer::prepareSeamlessTransition() { auto dream = m_nextDreamDecision->dream; if (!dream) return; - - // Check if we already have the path - auto path = dream->getCachedPath(); - if (!path.empty()) { - // Direct load if cached - PreloadClip(dream); - if (m_nextClip) { - m_nextClip->StartPlayback(0); - g_Log->Info("Prepared seamless transition to: %s (cached)", dream->uuid.c_str()); - } - return; - } - // Check if we have streaming URL - path = dream->getStreamingUrl(); - if (!path.empty()) { - // Direct load if streaming URL exists - PreloadClip(dream); - if (m_nextClip) { - m_nextClip->StartPlayback(0); - g_Log->Info("Prepared seamless transition to: %s (streaming URL)", dream->uuid.c_str()); - } - return; - } - - // We need to fetch async - std::thread([this, dream]() { - auto path = EDreamClient::GetDreamDownloadLink(dream->uuid); - if (path.empty()) { - g_Log->Error("Failed to get download link for seamless transition: %s", dream->uuid.c_str()); - return; - } - - dream->setStreamingUrl(path); - - if (PreloadClip(dream)) { - if (m_nextClip) { - m_nextClip->StartPlayback(0); - g_Log->Info("Prepared seamless transition to: %s (async fetch)", dream->uuid.c_str()); - } - } - }).detach(); - - g_Log->Info("Initiated async preparation for seamless transition to: %s", dream->uuid.c_str()); + RequestPreloadClip(dream, true); } void CPlayer::prepareCrossfadeTransition() { @@ -2052,37 +2193,72 @@ void CPlayer::prepareCrossfadeTransition() { auto dream = m_nextDreamDecision->dream; if (!dream) return; - - // Check if we already have the path - auto path = dream->getCachedPath(); - if (!path.empty()) { - // Direct load if cached - PreloadClip(dream); - g_Log->Info("Prepared crossfade transition to: %s (cached)", dream->uuid.c_str()); + + RequestPreloadClip(dream, false); +} + +void CPlayer::RequestPreloadClip( + const std::shared_ptr& dream, bool seamless) +{ + if (!dream) + return; + + const auto cachedPath = dream->getCachedPath(); + const auto streamingPath = dream->getStreamingUrl(); + if (!cachedPath.empty() || !streamingPath.empty()) + { + if (PreloadClip(dream) && seamless && m_nextClip) + m_nextClip->StartPlayback(0); return; } - - // Check if we have streaming URL - path = dream->getStreamingUrl(); - if (!path.empty()) { - // Direct load if streaming URL exists - PreloadClip(dream); - g_Log->Info("Prepared crossfade transition to: %s (streaming URL)", dream->uuid.c_str()); + + if (m_PreloadingNextClip && m_PreloadingDreamUUID == dream->uuid) + return; + + m_PreloadingNextClip = true; + m_PreloadingDreamUUID = dream->uuid; + + std::scoped_lock taskLock(m_backgroundTaskMutex); + m_backgroundTasks.emplace_back(std::async(std::launch::async, + [this, dream, seamless]() { + const auto path = EDreamClient::GetDreamDownloadLink(dream->uuid); + if (m_shutdownFlag.load()) + return; + EnqueuePreparedPreload(dream, path, seamless); + })); + + g_Log->Info("Fetching video URL for %s transition on a network worker: %s", + seamless ? "seamless" : "crossfade", dream->uuid.c_str()); +} + +void CPlayer::ApplyPreparedPreload( + const std::shared_ptr& dream, + const std::string& streamingPath, bool seamless) +{ + writer_lock lock(m_UpdateMutex); + + if (!dream || !m_nextDreamDecision || !m_nextDreamDecision->dream || + m_nextDreamDecision->dream->uuid != dream->uuid) + { + g_Log->Info("Discarding stale prepared preload result"); return; } - // We need to fetch async - std::thread([this, dream]() { - auto path = EDreamClient::GetDreamDownloadLink(dream->uuid); - dream->setStreamingUrl(path); - - if (!path.empty()) { - PreloadClip(dream); - g_Log->Info("Prepared crossfade transition to: %s (async fetch)", dream->uuid.c_str()); + if (streamingPath.empty()) + { + g_Log->Error("Failed to get video URL for transition to %s", + dream->uuid.c_str()); + if (m_PreloadingDreamUUID == dream->uuid) + { + m_PreloadingNextClip = false; + m_PreloadingDreamUUID.clear(); } - }).detach(); - - g_Log->Info("Initiated async preparation for: %s", dream->uuid.c_str()); + return; + } + + dream->setStreamingUrl(streamingPath); + if (PreloadClip(dream) && seamless && m_nextClip) + m_nextClip->StartPlayback(0); } bool CPlayer::PreloadClip(const std::shared_ptr& dream) { diff --git a/client_generic/Client/Player.h b/client_generic/Client/Player.h index 4c80023f..efb0859d 100644 --- a/client_generic/Client/Player.h +++ b/client_generic/Client/Player.h @@ -1,8 +1,11 @@ #ifndef _PLAYER_H_ #define _PLAYER_H_ -#include -#include +#include +#include +#include +#include +#include #ifdef WIN32 //#include @@ -43,12 +46,54 @@ class CPlayer : public Base::CSingleton void SetOfflineMode(bool offline); bool IsOfflineMode() const; private: - bool m_hasStarted = false; + std::atomic m_hasStarted{false}; - bool m_offlineMode; + std::atomic m_offlineMode{false}; - std::atomic m_shutdownFlag{false}; - std::shared_ptr m_startupThread; + std::atomic m_shutdownFlag{false}; + std::shared_ptr m_startupThread; + + enum class PendingCommandType { + ChangePlaylist, + PlayDream, + PlayPreparedDream, + PreloadPreparedDream, + InitializePlaylist + }; + + struct PendingCommand { + PendingCommandType type; + std::string uuid; + int64_t frameNumber = -1; + std::shared_ptr dream; + std::string streamingPath; + bool seamless = false; + std::string resumeDreamUuid; + bool offline = false; + }; + + std::mutex m_pendingCommandMutex; + std::deque m_pendingCommands; + std::thread::id m_updateThreadId; + std::mutex m_backgroundTaskMutex; + std::vector> m_backgroundTasks; + + void ProcessPendingCommands(); + void EnqueuePlaylistInitialization(std::string playlistUuid, + std::string resumeDreamUuid, + bool offline); + void EnqueuePreparedDream(std::shared_ptr dream, + std::string streamingPath, int64_t frameNumber); + void ApplyPreparedDream(const std::shared_ptr& dream, + int64_t frameNumber); + void RequestPreloadClip(const std::shared_ptr& dream, + bool seamless); + void EnqueuePreparedPreload(std::shared_ptr dream, + std::string streamingPath, bool seamless); + void ApplyPreparedPreload(const std::shared_ptr& dream, + const std::string& streamingPath, bool seamless); + void ReapBackgroundTasks(); + void WaitForBackgroundTasks(); ContentDecoder::spCClip m_currentClip; ContentDecoder::spCClip m_nextClip; @@ -240,8 +285,10 @@ class CPlayer : public Base::CSingleton // Get the PlaylistManager PlaylistManager& GetPlaylistManager() { return *m_playlistManager; } - void PlayDreamNow(std::string_view _uuid, int64_t frameNumber); - void ResetPlaylist(); + void PlayDreamNow(std::string_view _uuid, int64_t frameNumber); + void EnqueuePlayDream(std::string uuid, int64_t frameNumber = -1); + void EnqueuePlaylistChange(std::string uuid); + void ResetPlaylist(); // Set playlist from the start bool SetPlaylist(const std::string& playlistUUID, bool fetchPlaylist); diff --git a/client_generic/Client/SettingsDialogVulkan.cpp b/client_generic/Client/SettingsDialogVulkan.cpp new file mode 100644 index 00000000..14835f45 --- /dev/null +++ b/client_generic/Client/SettingsDialogVulkan.cpp @@ -0,0 +1,556 @@ +#if !defined(WIN32) && !defined(MAC) + +#include "SettingsDialogVulkan.h" + +#include "CacheManager.h" +#include "ContentDownloader.h" +#include "EDreamClient.h" +#include "PlatformUtils.h" +#include "PlatformUtils_Internal.h" +#include "Player.h" +#include "ServerConfig.h" +#include "Settings.h" +#include "storage.h" +#include "client.h" +#include "Log.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#ifdef HAVE_WAYLAND +#include +#endif + +namespace { + +#ifdef STAGE +constexpr const char* kUrlCreateAccount = "https://stage.infinidream.ai/account"; +constexpr const char* kUrlHelp = "https://stage.infinidream.ai/help"; +constexpr const char* kUrlWebRemote = "https://stage.infinidream.ai/rc"; +constexpr const char* kUrlPlaylists = "https://stage.infinidream.ai/playlists"; +#else +constexpr const char* kUrlCreateAccount = "https://alpha.infinidream.ai/account"; +constexpr const char* kUrlHelp = "https://alpha.infinidream.ai/help"; +constexpr const char* kUrlWebRemote = "https://alpha.infinidream.ai/rc"; +constexpr const char* kUrlPlaylists = "https://alpha.infinidream.ai/playlists"; +#endif + +static float g_uiScale = 1.0f; // refreshed from PlatformUtils_GetUIScale() on each show +static inline float S(float v) { return v * g_uiScale; } + +std::atomic g_showRequested{false}; +std::atomic g_visible{false}; +std::atomic g_wasPausedBeforeDialog{false}; +std::atomic g_wasUserPausedBeforeDialog{false}; +std::atomic g_pausedBySettingsDialog{false}; +std::atomic g_fontsLoaded{false}; + +ImFont* g_regularUiFont = nullptr; +ImFont* g_boldUiFont = nullptr; + +char g_nicknameBuf[256] = {}; +char g_codeBuf[32] = {}; +char g_previousLoginEmailBuf[256] = {}; +bool g_hasPreviousLoginEmail = false; +bool g_sentCode = false; +char g_statusBuf[256] = {}; +char g_errorPopupMessage[512] = {}; +std::string g_versionText; + +double g_playerFps = 23.0; +double g_displayFps = 60.0; +bool g_vsync = false; +bool g_preserveAR = false; +bool g_quietMode = true; +bool g_showAttribution = false; + +char g_contentDirBuf[512] = {}; +bool g_unlimitedCache = false; +int g_cacheSizeGb = 10; + +bool g_useProxy = false; +char g_proxyHostBuf[512] = {}; +char g_proxyLoginBuf[256] = {}; +char g_proxyPasswordBuf[256] = {}; +bool g_debugLog = false; +char g_serverBuf[512] = {}; + +// --------------------------------------------------------------------------- +// Font loading +// --------------------------------------------------------------------------- + +static std::string FindSystemFont(const char* name) +{ + static const char* const kDirs[] = { + "/usr/share/fonts/TTF/", + "/usr/share/fonts/truetype/", + "/usr/share/fonts/noto/", + "/usr/share/fonts/truetype/noto/", + "/usr/share/fonts/truetype/liberation/", + "/usr/share/fonts/truetype/dejavu/", + "/usr/share/fonts/", + nullptr, + }; + for (int i = 0; kDirs[i]; ++i) + { + std::string path = std::string(kDirs[i]) + name; + if (FILE* f = fopen(path.c_str(), "rb")) + { + fclose(f); + return path; + } + } + return {}; +} + +static void LoadDialogFonts() +{ + if (g_fontsLoaded.load(std::memory_order_acquire)) return; + + ImGuiIO& io = ImGui::GetIO(); + + static const char* const kRegular[] = { + "NotoSans-Regular.ttf", "NotoSans[wdth,wght].ttf", + "LiberationSans-Regular.ttf", "DejaVuSans.ttf", nullptr, + }; + static const char* const kBold[] = { + "NotoSans-Bold.ttf", "NotoSans[wdth,wght].ttf", + "LiberationSans-Bold.ttf", "DejaVuSans-Bold.ttf", nullptr, + }; + + std::string regular, bold; + for (int i = 0; kRegular[i] && regular.empty(); ++i) + regular = FindSystemFont(kRegular[i]); + for (int i = 0; kBold[i] && bold.empty(); ++i) + bold = FindSystemFont(kBold[i]); + + ImFontConfig cfg; + cfg.OversampleH = 2; + cfg.OversampleV = 2; + + constexpr float kUiTextSize = 20.f; // design-space baseline; always used as S(kUiTextSize) + + if (!regular.empty()) + { + g_regularUiFont = io.Fonts->AddFontFromFileTTF(regular.c_str(), S(kUiTextSize), &cfg); + g_Log->Info("SettingsDialog: loaded regular font '%s' at %.0fpx (uiScale=%.2f)", + regular.c_str(), S(kUiTextSize), g_uiScale); + } + else + { + g_Log->Warning("SettingsDialog: no regular font found; using embedded fallback"); + } + + if (!bold.empty()) + { + g_boldUiFont = io.Fonts->AddFontFromFileTTF(bold.c_str(), S(kUiTextSize), &cfg); + g_Log->Info("SettingsDialog: loaded bold font '%s' at %.0fpx", bold.c_str(), S(kUiTextSize)); + } + + g_fontsLoaded.store(true, std::memory_order_release); +} + +// --------------------------------------------------------------------------- +// Clipboard +// --------------------------------------------------------------------------- + +static const char* GetClipboardText(void*) +{ + static std::string s_buf; + s_buf.clear(); + static const char* const kCmds[] = { + "wl-paste --no-newline 2>/dev/null", + "xclip -selection clipboard -o 2>/dev/null", + "xsel --clipboard --output 2>/dev/null", + nullptr, + }; + for (int i = 0; kCmds[i]; ++i) + { + FILE* fp = popen(kCmds[i], "r"); + if (!fp) continue; + char buf[256]; + while (fgets(buf, sizeof buf, fp)) + s_buf += buf; + pclose(fp); + if (!s_buf.empty()) return s_buf.c_str(); + } + return s_buf.c_str(); +} + +// --------------------------------------------------------------------------- +// Inlined helpers and state +// --------------------------------------------------------------------------- + +#include "SettingsDialogVulkan/SettingsDialogVulkan.CommonTextUi.inl" +#include "SettingsDialogVulkan/SettingsDialogVulkan.CommonSettingsState.inl" +#include "SettingsDialogVulkan/SettingsDialogVulkan.DialogLifecycle.inl" + +// --------------------------------------------------------------------------- +// Tab draw functions +// --------------------------------------------------------------------------- + +static void DrawAccountTab() +{ + #include "SettingsDialogVulkan/SettingsDialogVulkan.TabAccount.inl" +} + +static void DrawControlsTab() +{ + #include "SettingsDialogVulkan/SettingsDialogVulkan.TabControls.inl" +} + +static void DrawDiskTab() +{ + #include "SettingsDialogVulkan/SettingsDialogVulkan.TabDisk.inl" +} + +static void DrawDisplayTab() +{ + #include "SettingsDialogVulkan/SettingsDialogVulkan.TabDisplay.inl" +} + +static void DrawAdvancedTab() +{ + #include "SettingsDialogVulkan/SettingsDialogVulkan.TabAdvanced.inl" +} + +// --------------------------------------------------------------------------- +// Dialog layout +// --------------------------------------------------------------------------- + +static void DrawSettingsDialog(float viewportW, float viewportH) +{ + const float targetWidth = S(541.f); + const float targetHeight = S(450.f); + const float windowWidth = (viewportW > (targetWidth + S(64.f))) ? targetWidth : (viewportW - S(32.f)); + const float windowHeight = (viewportH > (targetHeight + S(64.f))) ? targetHeight : (viewportH - S(32.f)); + const ImVec2 windowSize((windowWidth < S(460.f)) ? S(460.f) : windowWidth, + (windowHeight < S(340.f)) ? S(340.f) : windowHeight); + ImGui::SetNextWindowSize(windowSize, ImGuiCond_Always); + ImGui::SetNextWindowPos(ImVec2((viewportW - windowSize.x) * 0.5f, (viewportH - windowSize.y) * 0.5f), + ImGuiCond_Always); + + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, S(12.f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(S(18.f), S(16.f))); + ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, S(6.f)); + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(S(10.f), S(10.f))); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.98f, 0.98f, 0.98f, 1.00f)); + ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.82f, 0.82f, 0.82f, 1.00f)); + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.08f, 0.08f, 0.08f, 1.00f)); + ImGui::PushStyleColor(ImGuiCol_TextDisabled, ImVec4(0.40f, 0.40f, 0.40f, 1.00f)); + ImGui::PushStyleColor(ImGuiCol_Tab, ImVec4(0.92f, 0.92f, 0.92f, 1.00f)); + ImGui::PushStyleColor(ImGuiCol_TabActive, ImVec4(0.98f, 0.98f, 0.98f, 1.00f)); + ImGui::PushStyleColor(ImGuiCol_TabHovered, ImVec4(0.95f, 0.95f, 0.95f, 1.00f)); + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.93f, 0.93f, 0.93f, 1.00f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.88f, 0.88f, 0.88f, 1.00f)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(0.84f, 0.84f, 0.84f, 1.00f)); + ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(1.00f, 1.00f, 1.00f, 1.00f)); + ImGui::PushStyleColor(ImGuiCol_FrameBgHovered,ImVec4(0.97f, 0.97f, 0.97f, 1.00f)); + ImGui::PushStyleColor(ImGuiCol_FrameBgActive, ImVec4(0.95f, 0.95f, 0.95f, 1.00f)); + + ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoCollapse | + ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse; + if (ImGui::Begin("##SettingsDialog", nullptr, flags)) + { + const float footerHeight = S(44.f); + const float separatorReserve = ImGui::GetStyle().ItemSpacing.y + S(2.f); + float contentHeight = ImGui::GetContentRegionAvail().y - footerHeight - separatorReserve; + if (contentHeight < S(120.f)) contentHeight = S(120.f); + + ImGui::BeginChild("settings_content_region", ImVec2(0.f, contentHeight), false, + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); + + // Flat tab strip with hairline + accent underline on active tab. + ImGui::PushStyleColor(ImGuiCol_Tab, ImVec4(0.f, 0.f, 0.f, 0.f)); + ImGui::PushStyleColor(ImGuiCol_TabHovered, ImVec4(0.f, 0.f, 0.f, 0.06f)); + ImGui::PushStyleColor(ImGuiCol_TabActive, ImVec4(0.f, 0.f, 0.f, 0.f)); + ImGui::PushStyleColor(ImGuiCol_TabUnfocused, ImVec4(0.f, 0.f, 0.f, 0.f)); + ImGui::PushStyleColor(ImGuiCol_TabUnfocusedActive, ImVec4(0.f, 0.f, 0.f, 0.f)); + ImGui::PushStyleVar(ImGuiStyleVar_TabBorderSize, 0.f); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(S(14.f), S(8.f))); + + bool activeTabFound = false; + ImVec2 activeTabMin(0.f, 0.f); + ImVec2 activeTabMax(0.f, 0.f); + const auto drawTabItem = [&](const char* label, void (*body)()) { + const bool open = ImGui::BeginTabItem(label); + if (open && !activeTabFound) + { + activeTabMin = ImGui::GetItemRectMin(); + activeTabMax = ImGui::GetItemRectMax(); + activeTabFound = true; + } + if (open) + { + ImGui::PopStyleVar(); // FramePadding + body(); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(S(14.f), S(8.f))); + ImGui::EndTabItem(); + } + }; + + if (ImGui::BeginTabBar("settings_tabs")) + { + drawTabItem("Account", DrawAccountTab); + drawTabItem("Controls", DrawControlsTab); + drawTabItem("Disk", DrawDiskTab); + drawTabItem("Display", DrawDisplayTab); + drawTabItem("Advanced", DrawAdvancedTab); + ImGui::EndTabBar(); + } + + ImGui::PopStyleVar(2); + ImGui::PopStyleColor(5); + + if (activeTabFound) + { + ImDrawList* dl = ImGui::GetWindowDrawList(); + const ImU32 borderCol = ImGui::GetColorU32(ImGuiCol_Border); + const ImU32 accentCol = ImGui::ColorConvertFloat4ToU32(ImVec4(0.f, 0.478f, 1.f, 1.f)); + const ImVec2 wPos = ImGui::GetWindowPos(); + const float wW = ImGui::GetWindowWidth(); + const float lineY = activeTabMax.y; + dl->AddLine(ImVec2(wPos.x, lineY), ImVec2(wPos.x + wW, lineY), borderCol, 1.f); + dl->AddLine(ImVec2(activeTabMin.x, lineY), ImVec2(activeTabMax.x, lineY), accentCol, S(2.f)); + } + + ImGui::EndChild(); + + ImGui::Separator(); + ImGui::BeginChild("settings_footer_region", ImVec2(0.f, 0.f), false, + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); + { + const float helpButtonSize = S(24.f); + const float closeButtonWidth = S(90.f); + const float closeButtonHeight = S(32.f); + const float originX = ImGui::GetCursorPosX(); + const float contentWidth = ImGui::GetContentRegionAvail().x; + const float contentHeight = ImGui::GetContentRegionAvail().y; + const float rowCenterY = ImGui::GetCursorPosY() + (contentHeight * 0.5f); + const float buttonY = rowCenterY - (closeButtonHeight * 0.5f); + const float textY = rowCenterY - (ImGui::GetTextLineHeight() * 0.5f); + const float closeX = originX + contentWidth - closeButtonWidth; + + ImGui::SetCursorPos(ImVec2(closeX, buttonY)); + if (ImGui::Button("Close", ImVec2(closeButtonWidth, closeButtonHeight))) + CloseDialog(true); + + // Help button: InvisibleButton + manual draw for pixel-perfect "?" centering. + ImGui::SetCursorPos(ImVec2(originX, rowCenterY - (helpButtonSize * 0.5f))); + const bool helpClicked = ImGui::InvisibleButton("##help_btn", ImVec2(helpButtonSize, helpButtonSize)); + { + ImDrawList* dl = ImGui::GetWindowDrawList(); + const ImVec2 btnMin = ImGui::GetItemRectMin(); + const ImVec2 center(btnMin.x + helpButtonSize * 0.5f, btnMin.y + helpButtonSize * 0.5f); + const ImU32 bgCol = ImGui::IsItemActive() ? ImGui::GetColorU32(ImGuiCol_ButtonActive) : + ImGui::IsItemHovered() ? ImGui::GetColorU32(ImGuiCol_ButtonHovered) : + ImGui::GetColorU32(ImGuiCol_Button); + dl->AddCircleFilled(center, helpButtonSize * 0.5f, bgCol, 24); + dl->AddCircle(center, helpButtonSize * 0.5f, ImGui::GetColorU32(ImGuiCol_Border), 24, 1.f); + const char* q = "?"; + const ImVec2 qs = ImGui::CalcTextSize(q); + dl->AddText(ImVec2(center.x - qs.x * 0.5f, center.y - qs.y * 0.5f), + ImGui::GetColorU32(ImGuiCol_Text), q); + } + if (helpClicked) + PlatformUtils::OpenURLExternally(kUrlHelp); + + ImGui::SameLine(); + ImGui::SetCursorPosY(textY); + ImGui::TextDisabled("%s", g_versionText.c_str()); + } + ImGui::EndChild(); + } + ImGui::End(); + ImGui::PopStyleColor(13); + ImGui::PopStyleVar(4); + + if (ImGui::IsKeyPressed(ImGuiKey_Escape)) + CloseDialog(true); +} + +// --------------------------------------------------------------------------- +// Callback registered with ESSetShowPreferencesCallback +// --------------------------------------------------------------------------- + +static void OnShowPreferencesRequested() +{ + g_showRequested.store(true, std::memory_order_release); +} + +} // namespace + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +void SettingsDialogVulkan_Register() +{ + ESSetShowPreferencesCallback(OnShowPreferencesRequested); +} + +bool SettingsDialogVulkan_IsVisible() +{ + return g_visible.load(std::memory_order_acquire); +} + +void SettingsDialogVulkan_Toggle() +{ + if (g_visible.load(std::memory_order_acquire)) + CloseDialog(true); + else + OnShowPreferencesRequested(); +} + +void SettingsDialogVulkan_DrawIfNeeded() +{ + if (g_showRequested.load(std::memory_order_acquire)) + { + g_showRequested.store(false, std::memory_order_release); + + g_uiScale = PlatformUtils_GetUIScale(); + LoadDialogFonts(); + + // Re-apply io.FontDefault on every open: the wizard may have overwritten it + // with its own S(15) body font between dialog opens. + if (g_regularUiFont) + ImGui::GetIO().FontDefault = g_regularUiFont; + + ImGui::GetStyle().ScaleAllSizes(g_uiScale); + ImGui::StyleColorsLight(); + ImGui::GetIO().ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard; + ImGui::GetIO().GetClipboardTextFn = GetClipboardText; + + ResetFormForShow(); + ApplyDialogPauseState(true); + // Clear any stale key state (e.g. Escape stuck pressed from closing the + // wizard) so the dialog doesn't immediately close on its first frame. + ImGui::GetIO().ClearInputKeys(); + g_visible.store(true, std::memory_order_release); + } + + if (!g_visible.load(std::memory_order_acquire)) return; + + const float vpW = ImGui::GetIO().DisplaySize.x; + const float vpH = ImGui::GetIO().DisplaySize.y; + + // Lighten the dream scene under the dialog. + ImGui::GetBackgroundDrawList()->AddRectFilled( + ImVec2(0.f, 0.f), ImVec2(vpW, vpH), + ImGui::GetColorU32(ImVec4(1.f, 1.f, 1.f, 0.16f))); + + // Push the dialog font at S(20) so all dialog text renders at the correct + // size regardless of what FontSizeBase was set to during earlier frames + // (e.g. ProggyClean 13px added by ImGui on the very first frame). + if (g_regularUiFont) + ImGui::PushFont(g_regularUiFont, S(20.f)); + DrawSettingsDialog(vpW, vpH); + if (g_regularUiFont) + ImGui::PopFont(); +} + +#ifdef HAVE_WAYLAND +bool SettingsDialogVulkan_FeedKey(uint32_t evdev_key, xkb_keysym_t keysym, bool pressed, + struct xkb_state* state) +{ + if (!g_visible.load(std::memory_order_acquire)) return false; + + ImGuiIO& io = ImGui::GetIO(); + + ImGuiKey imkey = ImGuiKey_None; + switch (keysym) + { + case XKB_KEY_BackSpace: imkey = ImGuiKey_Backspace; break; + case XKB_KEY_Delete: imkey = ImGuiKey_Delete; break; + case XKB_KEY_Return: + case XKB_KEY_KP_Enter: imkey = ImGuiKey_Enter; break; + case XKB_KEY_Tab: imkey = ImGuiKey_Tab; break; + case XKB_KEY_Left: imkey = ImGuiKey_LeftArrow; break; + case XKB_KEY_Right: imkey = ImGuiKey_RightArrow; break; + case XKB_KEY_Home: imkey = ImGuiKey_Home; break; + case XKB_KEY_End: imkey = ImGuiKey_End; break; + case XKB_KEY_Insert: imkey = ImGuiKey_Insert; break; + case XKB_KEY_Escape: imkey = ImGuiKey_Escape; break; + default: break; + } + + bool ctrl = false; + bool shift = false; + if (state) + { + ctrl = xkb_state_mod_name_is_active(state, XKB_MOD_NAME_CTRL, XKB_STATE_MODS_EFFECTIVE) > 0; + shift = xkb_state_mod_name_is_active(state, XKB_MOD_NAME_SHIFT, XKB_STATE_MODS_EFFECTIVE) > 0; + } + + if (ctrl) + { + switch (keysym) + { + case XKB_KEY_a: imkey = ImGuiKey_A; break; + case XKB_KEY_c: imkey = ImGuiKey_C; break; + case XKB_KEY_v: imkey = ImGuiKey_V; break; + case XKB_KEY_x: imkey = ImGuiKey_X; break; + case XKB_KEY_z: imkey = ImGuiKey_Z; break; + default: break; + } + } + + if (shift && keysym == XKB_KEY_Insert) + { + io.AddKeyEvent(ImGuiMod_Ctrl, true); + io.AddKeyEvent(ImGuiKey_V, pressed); + io.AddKeyEvent(ImGuiMod_Ctrl, false); + io.AddKeyEvent(ImGuiMod_Shift, false); + return true; + } + + if (imkey != ImGuiKey_None) + io.AddKeyEvent(imkey, pressed); + + io.AddKeyEvent(ImGuiMod_Ctrl, ctrl); + + if (pressed && state && !ctrl && !(shift && keysym == XKB_KEY_Insert)) + { + const xkb_keycode_t xkb_keycode = evdev_key + 8; + char utf8charbuf[8] = {}; + int len = xkb_state_key_get_utf8(state, xkb_keycode, utf8charbuf, sizeof utf8charbuf); + if (len > 0 && + static_cast(utf8charbuf[0]) >= 0x20 && + static_cast(utf8charbuf[0]) != 0x7f) + { + io.AddInputCharactersUTF8(utf8charbuf); + } + } + + return true; +} +#endif // HAVE_WAYLAND + +void SettingsDialogVulkan_FeedMousePos(int x, int y) +{ + if (!g_visible.load(std::memory_order_acquire)) return; + ImGui::GetIO().AddMousePosEvent(static_cast(x), static_cast(y)); +} + +void SettingsDialogVulkan_FeedMouseButton(uint32_t button, bool pressed) +{ + if (!g_visible.load(std::memory_order_acquire)) return; + int imguiBtn = -1; + if (button == 272) imguiBtn = 0; // BTN_LEFT + else if (button == 273) imguiBtn = 1; // BTN_RIGHT + else if (button == 274) imguiBtn = 2; // BTN_MIDDLE + if (imguiBtn >= 0) + ImGui::GetIO().AddMouseButtonEvent(imguiBtn, pressed); +} + +#endif // !WIN32 && !MAC diff --git a/client_generic/Client/SettingsDialogVulkan.h b/client_generic/Client/SettingsDialogVulkan.h new file mode 100644 index 00000000..767628a9 --- /dev/null +++ b/client_generic/Client/SettingsDialogVulkan.h @@ -0,0 +1,34 @@ +#pragma once + +#if !defined(WIN32) && !defined(MAC) + +#include + +#ifdef HAVE_WAYLAND +#include +#endif + +/// Register the ESSetShowPreferencesCallback so Ctrl+, can trigger the dialog. +void SettingsDialogVulkan_Register(); + +/// True while the settings dialog is visible. +bool SettingsDialogVulkan_IsVisible(); + +/// Toggle: open if closed, close (saving) if open. +void SettingsDialogVulkan_Toggle(); + +/// Called from RendererVulkan inside the active ImGui frame — emits dialog +/// draw calls if visible. +void SettingsDialogVulkan_DrawIfNeeded(); + +/// Feed a Wayland keyboard event into ImGui while the dialog is visible. +/// Returns true if the event was consumed. +#ifdef HAVE_WAYLAND +bool SettingsDialogVulkan_FeedKey(uint32_t evdev_key, xkb_keysym_t keysym, bool pressed, + struct xkb_state* state); +#endif + +void SettingsDialogVulkan_FeedMousePos(int x, int y); +void SettingsDialogVulkan_FeedMouseButton(uint32_t button, bool pressed); + +#endif // !WIN32 && !MAC diff --git a/client_generic/Client/SettingsDialogVulkan/SettingsDialogVulkan.CommonSettingsState.inl b/client_generic/Client/SettingsDialogVulkan/SettingsDialogVulkan.CommonSettingsState.inl new file mode 100644 index 00000000..501af00f --- /dev/null +++ b/client_generic/Client/SettingsDialogVulkan/SettingsDialogVulkan.CommonSettingsState.inl @@ -0,0 +1,85 @@ +// Load/save settings state — mirrors SettingsDialogWin32.CommonSettingsState.inl. +// Linux omits: blackout_monitors (multi-monitor), keep_screensaver_enabled (not applicable). + +static void CopySettingToBuf(const char* key, std::string_view fallback, char* outBuf, size_t outSize) +{ + const std::string value = g_Settings()->Get(key, std::string(fallback)); + std::strncpy(outBuf, value.c_str(), outSize - 1); + outBuf[outSize - 1] = '\0'; +} + +static void LoadSettingsForShow() +{ + CopySettingToBuf("settings.generator.nickname", std::string(), g_nicknameBuf, sizeof g_nicknameBuf); + g_codeBuf[0] = '\0'; + g_sentCode = false; + g_previousLoginEmailBuf[0] = '\0'; + g_hasPreviousLoginEmail = false; + g_versionText = PlatformUtils::GetAppVersion(); + + g_playerFps = g_Settings()->Get("settings.player.player_fps", 23.0); + g_displayFps = g_Settings()->Get("settings.player.display_fps", 60.0); + g_vsync = g_Settings()->Get("settings.player.vbl_sync", false); + g_preserveAR = g_Settings()->Get("settings.player.preserve_AR", false); + g_quietMode = g_Settings()->Get("settings.player.quiet_mode", true); + g_showAttribution = g_Settings()->Get("settings.app.attributionpng", false); + + CopySettingToBuf("settings.content.sheepdir", std::string(), g_contentDirBuf, sizeof g_contentDirBuf); + g_unlimitedCache = g_Settings()->Get("settings.content.unlimited_cache", false); + g_cacheSizeGb = g_Settings()->Get("settings.content.cache_size", 10); + if (g_cacheSizeGb <= 0) + { + g_cacheSizeGb = 10; + g_unlimitedCache = true; + } + + g_useProxy = g_Settings()->Get("settings.content.use_proxy", false); + CopySettingToBuf("settings.content.proxy", std::string(), g_proxyHostBuf, sizeof g_proxyHostBuf); + CopySettingToBuf("settings.content.proxy_username", std::string(), g_proxyLoginBuf, sizeof g_proxyLoginBuf); + CopySettingToBuf("settings.content.proxy_password", std::string(), g_proxyPasswordBuf, sizeof g_proxyPasswordBuf); + g_debugLog = g_Settings()->Get("settings.app.log", false); + CopySettingToBuf("settings.content.server", ServerConfig::DEFAULT_DREAM_SERVER, g_serverBuf, sizeof g_serverBuf); + + g_statusBuf[0] = '\0'; + g_errorPopupMessage[0] = '\0'; +} + +static void SaveSettings() +{ + if (g_playerFps < 0.1) g_playerFps = 20.0; + if (g_displayFps < 0.1) g_displayFps = 60.0; + if (g_cacheSizeGb < 1) g_cacheSizeGb = 1; + + g_Settings()->Set("settings.player.player_fps", g_playerFps); + g_Settings()->Set("settings.player.display_fps", g_displayFps); + g_Settings()->Set("settings.player.DisplayMode", 2); + g_Settings()->Set("settings.player.vbl_sync", g_vsync); + g_Settings()->Set("settings.player.preserve_AR", g_preserveAR); + g_Settings()->Set("settings.player.quiet_mode", g_quietMode); + g_Settings()->Set("settings.app.attributionpng", g_showAttribution); + + g_Settings()->Set("settings.content.sheepdir", std::string(g_contentDirBuf)); + g_Settings()->Set("settings.content.unlimited_cache", g_unlimitedCache); + g_Settings()->Set("settings.content.cache_size", g_cacheSizeGb); + + g_Settings()->Set("settings.content.use_proxy", g_useProxy); + g_Settings()->Set("settings.content.proxy", std::string(g_proxyHostBuf)); + g_Settings()->Set("settings.content.proxy_username", std::string(g_proxyLoginBuf)); + g_Settings()->Set("settings.content.proxy_password", std::string(g_proxyPasswordBuf)); + g_Settings()->Set("settings.app.log", g_debugLog); + g_Settings()->Set("settings.content.server", std::string(g_serverBuf)); + + g_Settings()->Set("settings.generator.nickname", std::string(g_nicknameBuf)); + g_Settings()->Storage()->Commit(); + + if (!g_unlimitedCache) + { + const std::uintmax_t cacheSize = static_cast(g_cacheSizeGb) * 1024ull * 1024ull * 1024ull; + Cache::CacheManager::getInstance().resizeCache(cacheSize); + } +} + +static void ResetFormForShow() +{ + LoadSettingsForShow(); +} diff --git a/client_generic/Client/SettingsDialogVulkan/SettingsDialogVulkan.CommonTextUi.inl b/client_generic/Client/SettingsDialogVulkan/SettingsDialogVulkan.CommonTextUi.inl new file mode 100644 index 00000000..03f259f5 --- /dev/null +++ b/client_generic/Client/SettingsDialogVulkan/SettingsDialogVulkan.CommonTextUi.inl @@ -0,0 +1,141 @@ +// Shared text-field and widget helpers — mirrors SettingsDialogWin32.CommonTextUi.inl. + +static void TrimWhitespaceInPlace(char* s) +{ + if (!s) return; + size_t len = std::strlen(s); + size_t first = 0; + while (first < len && std::isspace(static_cast(s[first])) != 0) + ++first; + size_t last = len; + while (last > first && std::isspace(static_cast(s[last - 1])) != 0) + --last; + if (first > 0) + std::memmove(s, s + first, last - first); + s[last - first] = '\0'; +} + +static void StripNonDigits(char* s) +{ + if (!s) return; + size_t w = 0; + for (size_t r = 0; s[r] != '\0'; ++r) + { + if (std::isdigit(static_cast(s[r])) != 0) + { + s[w++] = s[r]; + if (w >= 6) break; + } + } + s[w] = '\0'; +} + +static bool InputTextWithPlaceholder(const char* id, const char* placeholder, char* buf, size_t bufSize, + ImGuiInputTextFlags flags = 0) +{ + const bool changed = ImGui::InputText(id, buf, bufSize, flags); + if (placeholder && placeholder[0] != '\0' && buf && buf[0] == '\0' && !ImGui::IsItemActive()) + { + const ImVec2 min = ImGui::GetItemRectMin(); + const ImVec2 max = ImGui::GetItemRectMax(); + const ImVec2 pad = ImGui::GetStyle().FramePadding; + const float textY = min.y + ((max.y - min.y - ImGui::GetTextLineHeight()) * 0.5f); + ImGui::GetWindowDrawList()->AddText( + ImVec2(min.x + pad.x, textY), + ImGui::GetColorU32(ImGuiCol_TextDisabled), + placeholder); + } + return changed; +} + +static void DrawFocusedInputDecoration(bool focused) +{ + ImDrawList* drawList = ImGui::GetWindowDrawList(); + const ImVec2 min = ImGui::GetItemRectMin(); + const ImVec2 max = ImGui::GetItemRectMax(); + const float rounding = ImGui::GetStyle().FrameRounding; + const ImU32 borderColor = focused ? IM_COL32(0, 122, 255, 255) : IM_COL32(224, 224, 224, 255); + + if (focused) + { + drawList->AddRect(ImVec2(min.x - 2.f, min.y - 2.f), ImVec2(max.x + 2.f, max.y + 2.f), + IM_COL32(64, 132, 255, 70), rounding + 1.f, 0, 3.f); + } + drawList->AddRect(min, max, borderColor, rounding, 0, focused ? 1.6f : 1.2f); +} + +static bool StyledCheckbox(const char* label, bool* value) +{ + const bool changed = ImGui::Checkbox(label, value); + ImDrawList* drawList = ImGui::GetWindowDrawList(); + const ImVec2 min = ImGui::GetItemRectMin(); + const float frameSize = ImGui::GetFrameHeight(); + const ImVec2 max(min.x + frameSize, min.y + frameSize); + const float rounding = ImGui::GetStyle().FrameRounding; + const bool focused = ImGui::IsItemActive() || ImGui::IsItemFocused(); + const ImU32 borderColor = focused ? IM_COL32(0, 122, 255, 255) : IM_COL32(224, 224, 224, 255); + drawList->AddRect(min, max, borderColor, rounding, 0, focused ? 1.6f : 1.2f); + return changed; +} + +static bool StyledRadioButton(const char* label, bool active, float scale = 1.0f) +{ + if (scale == 1.0f) + { + const bool changed = ImGui::RadioButton(label, active); + ImDrawList* drawList = ImGui::GetWindowDrawList(); + const ImVec2 min = ImGui::GetItemRectMin(); + const float frameSize = ImGui::GetFrameHeight(); + ImVec2 center(min.x + frameSize * 0.5f, min.y + frameSize * 0.5f); + center.x = static_cast(static_cast(center.x + 0.5f)); + center.y = static_cast(static_cast(center.y + 0.5f)); + const bool focused = ImGui::IsItemActive() || ImGui::IsItemFocused(); + const ImU32 borderColor = focused ? IM_COL32(0, 122, 255, 255) : IM_COL32(224, 224, 224, 255); + const float radius = ((frameSize - 1.0f) * 0.5f) - 0.5f; + drawList->AddCircle(center, radius, borderColor, 24, focused ? 1.6f : 1.2f); + return changed; + } + + const ImGuiStyle& style = ImGui::GetStyle(); + const ImVec2 cursor = ImGui::GetCursorScreenPos(); + const ImVec2 labelSize = ImGui::CalcTextSize(label, nullptr, true); + const float frameH = ImGui::GetFrameHeight(); + const float radioSize = static_cast(static_cast(frameH * scale)); + const float labelW = (labelSize.x > 0.f) ? style.ItemInnerSpacing.x + labelSize.x : 0.f; + const ImVec2 totalSize(radioSize + labelW, frameH); + + ImGui::InvisibleButton(label, totalSize); + const bool clicked = ImGui::IsItemClicked(); + const bool hovered = ImGui::IsItemHovered(); + const bool held = ImGui::IsItemActive(); + const bool focused = ImGui::IsItemActive() || ImGui::IsItemFocused(); + + ImDrawList* drawList = ImGui::GetWindowDrawList(); + ImVec2 center(cursor.x + radioSize * 0.5f, cursor.y + frameH * 0.5f); + center.x = static_cast(static_cast(center.x + 0.5f)); + center.y = static_cast(static_cast(center.y + 0.5f)); + const float radius = ((radioSize - 1.0f) * 0.5f) - 0.5f; + + const ImU32 bgColor = ImGui::GetColorU32((held && hovered) ? ImGuiCol_FrameBgActive + : hovered ? ImGuiCol_FrameBgHovered + : ImGuiCol_FrameBg); + drawList->AddCircleFilled(center, radius, bgColor, 24); + if (active) + { + const float padPx = radioSize / 6.0f; + const float pad = (padPx > 1.0f) ? static_cast(static_cast(padPx)) : 1.0f; + drawList->AddCircleFilled(center, radius - pad, ImGui::GetColorU32(ImGuiCol_CheckMark), 24); + } + + const ImU32 borderColor = focused ? IM_COL32(0, 122, 255, 255) : IM_COL32(224, 224, 224, 255); + drawList->AddCircle(center, radius, borderColor, 24, focused ? 1.6f : 1.2f); + + if (labelSize.x > 0.f) + { + const ImVec2 labelPos(cursor.x + radioSize + style.ItemInnerSpacing.x, + cursor.y + (frameH - labelSize.y) * 0.5f); + drawList->AddText(labelPos, ImGui::GetColorU32(ImGuiCol_Text), label); + } + + return clicked; +} diff --git a/client_generic/Client/SettingsDialogVulkan/SettingsDialogVulkan.DialogLifecycle.inl b/client_generic/Client/SettingsDialogVulkan/SettingsDialogVulkan.DialogLifecycle.inl new file mode 100644 index 00000000..cdd01cb1 --- /dev/null +++ b/client_generic/Client/SettingsDialogVulkan/SettingsDialogVulkan.DialogLifecycle.inl @@ -0,0 +1,45 @@ +// Open/close + playback-pause lifecycle — mirrors SettingsDialogWin32.DialogLifecycle.inl. + +static void ApplyDialogPauseState(bool visible) +{ + if (visible) + { + const bool wasPaused = g_Player().IsPaused(); + const bool wasUserPaused = g_Player().IsUserPaused(); + g_wasPausedBeforeDialog.store(wasPaused, std::memory_order_release); + g_wasUserPausedBeforeDialog.store(wasUserPaused, std::memory_order_release); + g_Player().SetPaused(true, /*isUserInitiated=*/true); + g_pausedBySettingsDialog.store(true, std::memory_order_release); + return; + } + + if (g_pausedBySettingsDialog.exchange(false, std::memory_order_acq_rel)) + { + const bool restorePaused = g_wasPausedBeforeDialog.load(std::memory_order_acquire); + const bool restoreUserPaused = g_wasUserPausedBeforeDialog.load(std::memory_order_acquire); + if (restorePaused && !restoreUserPaused) + { + if (g_Player().IsPausedForBuffering()) + { + g_Player().SetPaused(false, false); + g_Player().SetPaused(true, false); + } + else + { + g_Player().SetPaused(false, false); + } + } + else + { + g_Player().SetPaused(restorePaused, restoreUserPaused); + } + } +} + +static void CloseDialog(bool saveBeforeClose) +{ + if (saveBeforeClose) + SaveSettings(); + ApplyDialogPauseState(false); + g_visible.store(false, std::memory_order_release); +} diff --git a/client_generic/Client/SettingsDialogVulkan/SettingsDialogVulkan.TabAccount.inl b/client_generic/Client/SettingsDialogVulkan/SettingsDialogVulkan.TabAccount.inl new file mode 100644 index 00000000..1fd9c681 --- /dev/null +++ b/client_generic/Client/SettingsDialogVulkan/SettingsDialogVulkan.TabAccount.inl @@ -0,0 +1,243 @@ +// Account tab — mirrors SettingsDialogWin32.TabAccount.inl. +// Linux: uses ImGui modal popup instead of MessageBox for auth errors. + +const bool loggedIn = EDreamClient::IsLoggedIn(); +const float actionButtonWidth = S(110.f); +const float actionButtonHeight = S(36.f); +const float sidePadding = S(60.f); + +ImVec4 authColor = ImVec4(0.89f, 0.20f, 0.24f, 1.0f); +const char* authText = "Please sign in."; +if (loggedIn) +{ + authColor = ImVec4(0.18f, 0.72f, 0.29f, 1.0f); + authText = "Signed in"; +} +else if (g_sentCode) +{ + authColor = ImVec4(0.95f, 0.66f, 0.18f, 1.0f); + authText = "Check your e-mail for confirmation code"; +} + +const float availW = ImGui::GetContentRegionAvail().x; +const float availH = ImGui::GetContentRegionAvail().y; +const float safePanelWidth = (availW < S(300.f)) ? S(300.f) : availW; + +const float formHeight = loggedIn ? S(100.f) : S(240.f); +const float topPad = (availH - formHeight) * 0.5f; +if (topPad > 0.f) + ImGui::Dummy(ImVec2(0.f, topPad)); + +ImGui::BeginChild("account_centered_body", ImVec2(safePanelWidth, formHeight), false, + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoBackground); + +if (loggedIn) +{ + // Single row: [● Signed in as …] ··· [Sign out] + // The text baseline is vertically centred within the button height. + const float dotRadius = S(10.f); + const float lineHeight = ImGui::GetTextLineHeight(); + const float rowY = ImGui::GetCursorPosY() + S(4.f); // small top guard + const float textY = rowY + (actionButtonHeight - lineHeight) * 0.5f; + + // Green circle + "Signed in as" text + ImGui::SetCursorPos(ImVec2(sidePadding, textY)); + { + ImDrawList* drawList = ImGui::GetWindowDrawList(); + const ImVec2 dotCenter(ImGui::GetCursorScreenPos().x + dotRadius, + ImGui::GetCursorScreenPos().y + lineHeight * 0.5f); + drawList->AddCircleFilled(dotCenter, dotRadius, + ImGui::ColorConvertFloat4ToU32(authColor), 24); + ImGui::Dummy(ImVec2(dotRadius * 2.f, lineHeight)); + ImGui::SameLine(0.f, S(8.f)); + const std::string signedInText = std::string("Signed in as ") + g_nicknameBuf; + ImGui::TextUnformatted(signedInText.c_str()); + } + + // Sign out button: right-aligned on the same row + const float signOutX = safePanelWidth - sidePadding - actionButtonWidth; + ImGui::SetCursorPos(ImVec2(signOutX, rowY)); + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.00f, 0.48f, 1.00f, 1.00f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.10f, 0.56f, 1.00f, 1.00f)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(0.00f, 0.40f, 0.86f, 1.00f)); + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.f, 1.f, 1.f, 1.f)); + if (g_boldUiFont) ImGui::PushFont(g_boldUiFont); + if (ImGui::Button("Sign out", ImVec2(actionButtonWidth, actionButtonHeight))) + { + std::strncpy(g_previousLoginEmailBuf, g_nicknameBuf, sizeof g_previousLoginEmailBuf - 1); + g_previousLoginEmailBuf[sizeof g_previousLoginEmailBuf - 1] = '\0'; + g_hasPreviousLoginEmail = true; + EDreamClient::SignOut(); + g_sentCode = false; + g_codeBuf[0] = '\0'; + std::strncpy(g_statusBuf, "Signed out.", sizeof g_statusBuf - 1); + } + if (g_boldUiFont) ImGui::PopFont(); + ImGui::PopStyleColor(4); +} + +bool emailEnter = false; +bool codeEnter = false; +if (!loggedIn) +{ + ImGui::Spacing(); + const float contentW = safePanelWidth - sidePadding * 2.f; + const float fieldGap = S(10.f); + const float emailLabelW = ImGui::CalcTextSize("Email:").x; + const float codeLabelW = ImGui::CalcTextSize("Code:").x; + const float labelW = (emailLabelW > codeLabelW) ? emailLabelW : codeLabelW; + const float emailInputW = contentW - labelW - fieldGap; + const float codeInputW = S(96.f); + const float leftX = sidePadding; + + ImGui::SetCursorPosX(leftX); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Email:"); + ImGui::BeginDisabled(g_sentCode); + ImGui::SameLine(0.f, fieldGap); + ImGui::PushItemWidth(emailInputW); + emailEnter = InputTextWithPlaceholder("##email", "eg: john@smith.com", g_nicknameBuf, + sizeof g_nicknameBuf, ImGuiInputTextFlags_EnterReturnsTrue); + DrawFocusedInputDecoration(ImGui::IsItemActive() || ImGui::IsItemFocused()); + ImGui::PopItemWidth(); + ImGui::EndDisabled(); + + ImGui::SetCursorPosX(leftX); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Code:"); + ImGui::BeginDisabled(!g_sentCode); + ImGui::SameLine(0.f, fieldGap); + ImGui::PushItemWidth(codeInputW); + codeEnter = InputTextWithPlaceholder("##code", "6 digit code", g_codeBuf, sizeof g_codeBuf, + ImGuiInputTextFlags_CharsDecimal | + ImGuiInputTextFlags_EnterReturnsTrue); + StripNonDigits(g_codeBuf); + DrawFocusedInputDecoration(ImGui::IsItemActive() || ImGui::IsItemFocused()); + ImGui::PopItemWidth(); + ImGui::EndDisabled(); + + ImGui::SetCursorPosX(leftX); + { + ImDrawList* drawList = ImGui::GetWindowDrawList(); + const float dotRadius = S(10.f); + const float lineHeight = ImGui::GetTextLineHeight(); + const ImVec2 dotCenter(ImGui::GetCursorScreenPos().x + dotRadius, + ImGui::GetCursorScreenPos().y + lineHeight * 0.5f); + drawList->AddCircleFilled(dotCenter, dotRadius, ImGui::ColorConvertFloat4ToU32(authColor), 24); + ImGui::Dummy(ImVec2(dotRadius * 2.f, lineHeight)); + ImGui::SameLine(0.f, 8.f); + } + ImGui::TextUnformatted(authText); + + const float buttonRowW = actionButtonWidth * 2.f + ImGui::GetStyle().ItemSpacing.x; + const float buttonX = safePanelWidth - sidePadding - buttonRowW; + if (buttonX > 0.f) ImGui::SetCursorPosX(buttonX); + + if (!g_sentCode) + { + ImGui::BeginDisabled(); + ImGui::Button("Start Again", ImVec2(actionButtonWidth, actionButtonHeight)); + ImGui::EndDisabled(); + ImGui::SameLine(); + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.00f, 0.48f, 1.00f, 1.00f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.10f, 0.56f, 1.00f, 1.00f)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(0.00f, 0.40f, 0.86f, 1.00f)); + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.f, 1.f, 1.f, 1.f)); + if (g_boldUiFont) ImGui::PushFont(g_boldUiFont); + const bool sendClicked = ImGui::Button("Send Code", ImVec2(actionButtonWidth, actionButtonHeight)); + if (sendClicked || emailEnter) + { + TrimWhitespaceInPlace(g_nicknameBuf); + g_Settings()->Set("settings.generator.nickname", std::string(g_nicknameBuf)); + g_Settings()->Storage()->Commit(); + const EDreamClient::SendCodeResult result = EDreamClient::SendCode(); + g_sentCode = result.success; + if (result.success) + { + std::strncpy(g_statusBuf, + result.message.empty() ? "Check your e-mail for confirmation code" + : result.message.c_str(), + sizeof g_statusBuf - 1); + g_statusBuf[sizeof g_statusBuf - 1] = '\0'; + } + else + { + std::strncpy(g_errorPopupMessage, + result.message.empty() ? "Failed to send verification code." + : result.message.c_str(), + sizeof g_errorPopupMessage - 1); + g_errorPopupMessage[sizeof g_errorPopupMessage - 1] = '\0'; + ImGui::OpenPopup("##auth_error"); + } + } + if (g_boldUiFont) ImGui::PopFont(); + ImGui::PopStyleColor(4); + } + else + { + if (ImGui::Button("Start again", ImVec2(actionButtonWidth, actionButtonHeight))) + { + g_sentCode = false; + g_codeBuf[0] = '\0'; + std::strncpy(g_statusBuf, "Code flow restarted.", sizeof g_statusBuf - 1); + } + ImGui::SameLine(); + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.00f, 0.48f, 1.00f, 1.00f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.10f, 0.56f, 1.00f, 1.00f)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(0.00f, 0.40f, 0.86f, 1.00f)); + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.f, 1.f, 1.f, 1.f)); + if (g_boldUiFont) ImGui::PushFont(g_boldUiFont); + const bool validateClicked = ImGui::Button("Validate", ImVec2(actionButtonWidth, actionButtonHeight)); + if (validateClicked || codeEnter) + { + StripNonDigits(g_codeBuf); + const EDreamClient::ValidateCodeResult validateResult = + EDreamClient::ValidateCodeDetailed(std::string(g_codeBuf)); + if (validateResult.success) + { + const bool accountChanged = + g_hasPreviousLoginEmail && + std::strcmp(g_previousLoginEmailBuf, g_nicknameBuf) != 0; + EDreamClient::DidSignIn(); + g_sentCode = false; + std::strncpy(g_statusBuf, "Login successful.", sizeof g_statusBuf - 1); + if (accountChanged) + std::exit(0); + } + else + { + std::strncpy(g_errorPopupMessage, + validateResult.message.empty() + ? "Validation failed. Please request a new code and sign in again." + : validateResult.message.c_str(), + sizeof g_errorPopupMessage - 1); + g_errorPopupMessage[sizeof g_errorPopupMessage - 1] = '\0'; + ImGui::OpenPopup("##auth_error"); + } + } + if (g_boldUiFont) ImGui::PopFont(); + ImGui::PopStyleColor(4); + } + + ImGui::SetCursorPosX(sidePadding); + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.90f, 0.90f, 0.90f, 1.00f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.86f, 0.86f, 0.86f, 1.00f)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(0.82f, 0.82f, 0.82f, 1.00f)); + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.08f, 0.08f, 0.08f, 1.00f)); + if (ImGui::Button("Need an account? Create one", ImVec2(contentW, actionButtonHeight))) + PlatformUtils::OpenURLExternally(kUrlCreateAccount); + ImGui::PopStyleColor(4); +} + +ImGui::EndChild(); + +// Auth error popup (replaces Win32 MessageBox). +if (ImGui::BeginPopupModal("##auth_error", nullptr, + ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoTitleBar)) +{ + ImGui::TextWrapped("%s", g_errorPopupMessage); + ImGui::Spacing(); + if (ImGui::Button("OK", ImVec2(S(80.f), 0.f))) + ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); +} diff --git a/client_generic/Client/SettingsDialogVulkan/SettingsDialogVulkan.TabAdvanced.inl b/client_generic/Client/SettingsDialogVulkan/SettingsDialogVulkan.TabAdvanced.inl new file mode 100644 index 00000000..f1ee8b3d --- /dev/null +++ b/client_generic/Client/SettingsDialogVulkan/SettingsDialogVulkan.TabAdvanced.inl @@ -0,0 +1,82 @@ +// Advanced tab — mirrors SettingsDialogWin32.TabAdvanced.inl. +// Linux: omits "Keep screensaver enabled" (not applicable). + +const float availW = ImGui::GetContentRegionAvail().x; +const float leftInset = S(138.f); +const float proxyGroupTopGap = S(12.f); +const float proxyContentInsetX = S(6.f); +const float proxyContentInsetY = S(38.f); // below the "Proxy" bold header at S(20) font +const float labelInputGap = S(8.f); +const float fieldRowGap = S(2.f); + +#ifdef DEBUG +const char* serverLabel = "Server:"; +const float serverLabelW = ImGui::CalcTextSize(serverLabel).x; +ImGui::SetCursorPosX(leftInset); +ImGui::AlignTextToFramePadding(); +ImGui::TextUnformatted(serverLabel); +ImGui::SameLine(0.f, labelInputGap); +ImGui::PushItemWidth(availW - leftInset - serverLabelW - labelInputGap - S(8.f)); +ImGui::InputText("##server", g_serverBuf, sizeof g_serverBuf); +DrawFocusedInputDecoration(ImGui::IsItemActive() || ImGui::IsItemFocused()); +ImGui::PopItemWidth(); +ImGui::SetCursorPosY(ImGui::GetCursorPosY() + proxyGroupTopGap); +#endif + +ImGui::SetCursorPosY(ImGui::GetCursorPosY() + proxyGroupTopGap); +const float proxyGroupW = availW; +const float proxyGroupH = S(200.f); +ImGui::BeginChild("advanced_proxy_group", ImVec2(proxyGroupW, proxyGroupH), true, + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); + +if (g_boldUiFont) ImGui::PushFont(g_boldUiFont); +ImGui::SetCursorPos(ImVec2(proxyContentInsetX, S(2.f))); +ImGui::TextUnformatted("Proxy"); +if (g_boldUiFont) ImGui::PopFont(); + +const char* hostLabel = "Host:"; +const char* loginLabel = "Login:"; +const char* passwordLabel = "Password:"; +const float deltaLabelW = S(40.f); +const float hostLabelW = ImGui::CalcTextSize(hostLabel).x + deltaLabelW; +const float loginLabelW = ImGui::CalcTextSize(loginLabel).x + deltaLabelW; +const float passwordLabelW = ImGui::CalcTextSize(passwordLabel).x + deltaLabelW; +float labelW = hostLabelW; +if (loginLabelW > labelW) labelW = loginLabelW; +if (passwordLabelW > labelW) labelW = passwordLabelW; + +const float rowStartX = proxyContentInsetX; +const float labelRightX = rowStartX + labelW; +const float inputX = rowStartX + labelW + labelInputGap; +const float proxyInputRightPadding = S(20.f); +const float inputW = proxyGroupW - inputX - proxyContentInsetX - proxyInputRightPadding; +const float startY = proxyContentInsetY; +ImGui::SetCursorPos(ImVec2(inputX, startY)); +StyledCheckbox("Use Proxy", &g_useProxy); +ImGui::SetCursorPos(ImVec2(ImGui::GetCursorPosX(), ImGui::GetCursorPosY() + S(40.f))); + +const auto drawProxyRow = [&](const char* label, const char* id, char* buf, size_t bufSize, + ImGuiInputTextFlags flags) { + const float rowY = ImGui::GetCursorPosY() + fieldRowGap; + const float textWidth = ImGui::CalcTextSize(label).x; + const float frameHeight = ImGui::GetFrameHeight(); + const float textHeight = ImGui::GetTextLineHeight(); + const float labelY = rowY + (frameHeight - textHeight) * 0.5f; + + ImGui::SetCursorPos(ImVec2(labelRightX - textWidth, labelY)); + ImGui::TextUnformatted(label); + + ImGui::SetCursorPos(ImVec2(inputX, rowY)); + ImGui::PushItemWidth(inputW); + ImGui::InputText(id, buf, bufSize, flags); + DrawFocusedInputDecoration(ImGui::IsItemActive() || ImGui::IsItemFocused()); + ImGui::PopItemWidth(); +}; + +ImGui::SetCursorPos(ImVec2(rowStartX, startY + ImGui::GetFrameHeight() + fieldRowGap)); +drawProxyRow(hostLabel, "##proxy_host", g_proxyHostBuf, sizeof g_proxyHostBuf, 0); +drawProxyRow(loginLabel, "##proxy_login", g_proxyLoginBuf, sizeof g_proxyLoginBuf, 0); +drawProxyRow(passwordLabel, "##proxy_password", g_proxyPasswordBuf, sizeof g_proxyPasswordBuf, + ImGuiInputTextFlags_Password); + +ImGui::EndChild(); diff --git a/client_generic/Client/SettingsDialogVulkan/SettingsDialogVulkan.TabControls.inl b/client_generic/Client/SettingsDialogVulkan/SettingsDialogVulkan.TabControls.inl new file mode 100644 index 00000000..8cc8f991 --- /dev/null +++ b/client_generic/Client/SettingsDialogVulkan/SettingsDialogVulkan.TabControls.inl @@ -0,0 +1,77 @@ +// Controls tab — mirrors SettingsDialogWin32.TabControls.inl. +// Linux: no playlist icon texture (skip image rendering). + +const float sectionHeight = S(240.f); +const float panelGap = S(4.f); +const float panelInnerPadding = S(8.f); +const float buttonWidth = S(170.f); +const float buttonHeight = S(30.f); +const float footerRowHeight = S(40.f); +const float dividerWidth = S(1.f); +const ImVec4 sectionDividerColor(0.90f, 0.90f, 0.90f, 1.00f); + +const float rowStartY = ImGui::GetCursorPosY(); +const float availWidth = ImGui::GetContentRegionAvail().x; +const float panelWidth = (availWidth - (panelGap * 2.f) - dividerWidth) * 0.5f; + +auto drawControlPanel = [&](const char* panelId, const char* bodyText, const char* buttonLabel, const char* url) { + ImGui::BeginChild(panelId, ImVec2(panelWidth, sectionHeight), false, + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); + ImGui::SetCursorPos(ImVec2(panelInnerPadding, panelInnerPadding)); + ImGui::PushTextWrapPos(panelWidth - panelInnerPadding); + ImGui::TextWrapped("%s", bodyText); + ImGui::PopTextWrapPos(); + + const float buttonX = (panelWidth - buttonWidth) * 0.5f; + const float buttonY = sectionHeight - buttonHeight - S(10.f); + ImGui::SetCursorPos(ImVec2(buttonX, buttonY)); + if (ImGui::Button(buttonLabel, ImVec2(buttonWidth, buttonHeight))) + PlatformUtils::OpenURLExternally(url); + ImGui::EndChild(); +}; + +drawControlPanel("controls_remote_panel", + "Use the A and D keys to adjust the speed of playback. Press F1 to see more keyboard controls. You can also interact with the remote control installed on your phone, or from a web browser:", + "Open web remote", kUrlWebRemote); + +ImGui::SameLine(0.f, panelGap); +ImGui::BeginGroup(); +{ + const ImVec2 dividerTop = ImGui::GetCursorScreenPos(); + ImGui::Dummy(ImVec2(dividerWidth, sectionHeight)); + const ImVec2 dividerBottom(dividerTop.x, dividerTop.y + sectionHeight); + ImGui::GetWindowDrawList()->AddLine(dividerTop, dividerBottom, + ImGui::GetColorU32(sectionDividerColor), dividerWidth); +} +ImGui::EndGroup(); + +ImGui::SameLine(0.f, panelGap); +drawControlPanel("controls_playlist_panel", + "Change your dreams by selecting a playlist from the browser. Click the button on a thumbnail to start that playlist.", + "Open playlist browser", kUrlPlaylists); + +ImGui::SetCursorPosY(rowStartY + sectionHeight + panelGap); +ImGui::PushStyleColor(ImGuiCol_Separator, sectionDividerColor); +ImGui::Separator(); +ImGui::PopStyleColor(); +ImGui::SetCursorPosY(ImGui::GetCursorPosY() + panelGap); + +const float footerStartX = ImGui::GetCursorPosX(); +const float footerStartY = ImGui::GetCursorPosY(); +const float footerAvailWidth = ImGui::GetContentRegionAvail().x; +const float footerGap = S(12.f); +const char* footerLabel = "For more controls and explanation:"; + +const ImVec2 footerLabelSize = ImGui::CalcTextSize(footerLabel); + +const float footerGroupWidth = footerLabelSize.x + footerGap + buttonWidth; +const float footerGroupStartX = footerStartX + (footerAvailWidth - footerGroupWidth) * 0.5f; +const float footerTextY = footerStartY + (footerRowHeight - footerLabelSize.y) * 0.5f; +const float footerButtonY = footerStartY + (footerRowHeight - buttonHeight) * 0.5f; + +ImGui::SetCursorPos(ImVec2(footerGroupStartX, footerTextY)); +ImGui::TextUnformatted(footerLabel); + +ImGui::SetCursorPos(ImVec2(footerGroupStartX + footerLabelSize.x + footerGap, footerButtonY)); +if (ImGui::Button("View help page", ImVec2(buttonWidth, buttonHeight))) + PlatformUtils::OpenURLExternally(kUrlHelp); diff --git a/client_generic/Client/SettingsDialogVulkan/SettingsDialogVulkan.TabDisk.inl b/client_generic/Client/SettingsDialogVulkan/SettingsDialogVulkan.TabDisk.inl new file mode 100644 index 00000000..8c0c224a --- /dev/null +++ b/client_generic/Client/SettingsDialogVulkan/SettingsDialogVulkan.TabDisk.inl @@ -0,0 +1,140 @@ +// Disk tab — mirrors SettingsDialogWin32.TabDisk.inl. +// Linux: content folder is a plain text input (no native folder picker). + +const float contentWidth = ImGui::GetContentRegionAvail().x; +const float groupHeight = S(72.f); +const float horizontalInset = S(14.f); +const float bottomBorderReserve = S(10.f); +const float limitBoxH = S(118.f); +const float playlistGroupH = S(72.f); +const float playlistGroupGap = ImGui::GetStyle().ItemSpacing.y; +float cacheGroupHeight = ImGui::GetContentRegionAvail().y - groupHeight - playlistGroupH - playlistGroupGap - bottomBorderReserve; +if (cacheGroupHeight < limitBoxH + S(12.f)) + cacheGroupHeight = limitBoxH + S(12.f); + +ImGui::BeginChild("disk_cache_group", ImVec2(contentWidth, cacheGroupHeight), false, + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); + +const float bodyInset = S(18.f); +const float colGap = S(20.f); +const float rightColW = S(200.f); +const float leftColW = contentWidth - rightColW - bodyInset * 2.f - colGap; + +const float topY = ImGui::GetCursorPosY(); + +ImGui::SetCursorPos(ImVec2(bodyInset, topY)); +ImGui::PushTextWrapPos(bodyInset + leftColW); +ImGui::TextWrapped("Dreams are stored on your local disk so they can be played many times without redownloading."); +ImGui::PopTextWrapPos(); + +const double usedCacheGb = Cache::CacheManager::getInstance().getCacheSize(); +char usageText[96]; +std::snprintf(usageText, sizeof usageText, "It is currently using %.2f GB.", usedCacheGb); + +ImGui::SetCursorPosX(bodyInset); +ImGui::SetCursorPosY(ImGui::GetCursorPosY() + S(8.f)); +ImGui::TextUnformatted(usageText); + +const float rightX = bodyInset + leftColW + colGap; +const float rowGap = S(6.f); +const float boxInset = S(10.f); + +ImGui::SetCursorPos(ImVec2(rightX, topY)); +ImGui::BeginChild("disk_usage_limit_box", ImVec2(rightColW, limitBoxH), true, + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); + +ImGui::SetCursorPos(ImVec2(boxInset, S(6.f))); +if (g_boldUiFont) ImGui::PushFont(g_boldUiFont); +ImGui::TextUnformatted("Disk usage limit"); +if (g_boldUiFont) ImGui::PopFont(); + +ImGui::SetCursorPos(ImVec2(boxInset, ImGui::GetCursorPosY() + rowGap)); +if (StyledRadioButton("Unlimited", g_unlimitedCache, 0.8f)) + g_unlimitedCache = true; + +ImGui::SetCursorPos(ImVec2(boxInset, ImGui::GetCursorPosY() + rowGap)); +if (StyledRadioButton("##limited_cache", !g_unlimitedCache, 0.8f)) + g_unlimitedCache = false; +ImGui::SameLine(0.f, S(8.f)); +ImGui::AlignTextToFramePadding(); +ImGui::BeginDisabled(g_unlimitedCache); +ImGui::PushItemWidth(S(72.f)); +ImGui::InputInt("##max_cache_gb", &g_cacheSizeGb, 0, 0, ImGuiInputTextFlags_CharsDecimal); +if (g_cacheSizeGb < 0) g_cacheSizeGb = 0; +DrawFocusedInputDecoration(ImGui::IsItemActive() || ImGui::IsItemFocused()); +ImGui::PopItemWidth(); +ImGui::EndDisabled(); +ImGui::SameLine(0.f, S(8.f)); +ImGui::AlignTextToFramePadding(); +ImGui::TextUnformatted("GB"); + +ImGui::EndChild(); +ImGui::EndChild(); + +// Playlist status — shows current playlist name, cached/total dream count, and +// downloader status so a misconfigured account (e.g. stuck on a tiny test +// playlist) is immediately visible without needing to read log files. +{ + auto& pm = g_Player().GetPlaylistManager(); + auto& cm = Cache::CacheManager::getInstance(); + + const auto uuids = pm.getCurrentPlaylistUUIDs(); + const size_t total = pm.getPlaylistSize(); + size_t cached = 0; + for (const auto& uuid : uuids) + if (cm.hasDiskCachedItem(uuid)) ++cached; + + const std::string& playlistName = pm.getPlaylistName(); + const std::string dlStatus = g_ContentDownloader().m_gDownloader.GetDownloadStatus(); + + char nameText[256]; + std::snprintf(nameText, sizeof nameText, "%s", + playlistName.empty() ? "(none)" : playlistName.c_str()); + + char countText[64]; + std::snprintf(countText, sizeof countText, "%zu / %zu downloaded", cached, total); + + ImGui::BeginChild("disk_playlist_group", ImVec2(contentWidth, playlistGroupH), true, + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); + + // Use the inner content width (not the outer contentWidth) for right-alignment. + const float innerW = ImGui::GetContentRegionAvail().x; + const float countTextW = ImGui::CalcTextSize(countText).x; + + // Row 1: "Playlist" bold label followed immediately by the playlist name. + ImGui::SetCursorPos(ImVec2(horizontalInset, S(6.f))); + if (g_boldUiFont) ImGui::PushFont(g_boldUiFont); + ImGui::TextUnformatted("Playlist"); + if (g_boldUiFont) ImGui::PopFont(); + ImGui::SameLine(0.f, S(8.f)); + ImGui::PushTextWrapPos(horizontalInset + innerW - horizontalInset - countTextW - S(12.f)); + ImGui::TextUnformatted(nameText); + ImGui::PopTextWrapPos(); + + // Count right-aligned on the same row. + ImGui::SetCursorPos(ImVec2(innerW - horizontalInset - countTextW, S(6.f))); + ImGui::TextDisabled("%s", countText); + + // Row 2: downloader status (if any). + if (!dlStatus.empty()) + { + ImGui::SetCursorPos(ImVec2(horizontalInset, S(34.f))); + ImGui::TextDisabled("%s", dlStatus.c_str()); + } + + ImGui::EndChild(); +} + +ImGui::BeginChild("disk_content_folder_group", ImVec2(contentWidth, groupHeight), true, + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); +ImGui::SetCursorPos(ImVec2(horizontalInset, S(6.f))); +if (g_boldUiFont) ImGui::PushFont(g_boldUiFont); +ImGui::TextUnformatted("Content Folder"); +if (g_boldUiFont) ImGui::PopFont(); + +ImGui::SetCursorPos(ImVec2(horizontalInset, S(30.f))); +ImGui::PushItemWidth(contentWidth - (horizontalInset * 2.f)); +ImGui::InputText("##content_folder", g_contentDirBuf, sizeof g_contentDirBuf); +DrawFocusedInputDecoration(ImGui::IsItemActive() || ImGui::IsItemFocused()); +ImGui::PopItemWidth(); +ImGui::EndChild(); diff --git a/client_generic/Client/SettingsDialogVulkan/SettingsDialogVulkan.TabDisplay.inl b/client_generic/Client/SettingsDialogVulkan/SettingsDialogVulkan.TabDisplay.inl new file mode 100644 index 00000000..5c34a82a --- /dev/null +++ b/client_generic/Client/SettingsDialogVulkan/SettingsDialogVulkan.TabDisplay.inl @@ -0,0 +1,11 @@ +// Display tab — mirrors SettingsDialogWin32.TabDisplay.inl. + +const float availW = ImGui::GetContentRegionAvail().x; +const float availH = ImGui::GetContentRegionAvail().y; +const float checkSz = ImGui::GetFrameHeight(); +const float labelW = ImGui::CalcTextSize("Preserve Aspect Ratio").x; +const float totalW = checkSz + ImGui::GetStyle().ItemInnerSpacing.x + labelW; +const float cx = (availW - totalW) * 0.5f; +const float cy = (availH - checkSz) * 0.5f; +ImGui::SetCursorPos(ImVec2(cx > 0.f ? cx : 0.f, cy > 0.f ? cy : 0.f)); +StyledCheckbox("Preserve Aspect Ratio", &g_preserveAR); diff --git a/client_generic/Client/client.h b/client_generic/Client/client.h index 98cf476c..0a57f6d0 100644 --- a/client_generic/Client/client.h +++ b/client_generic/Client/client.h @@ -82,6 +82,7 @@ extern void ESShowPreferences(); typedef void (*ShowFirstTimeSetupCallback_t)(); extern void ESSetShowFirstTimeSetupCallback(ShowFirstTimeSetupCallback_t); extern void ESShowFirstTimeSetup(); +extern bool ESHasFirstTimeSetupCallback(); #if defined(WIN32) /// True while the first-run ImGui wizard is active (used to skip game HUD compositing). @@ -874,25 +875,30 @@ class CElectricSheep if (sealedSession.empty()) { // Try to get a sealed session interactively before the window opens. - if (!EDreamClient::LoginWithMagicLinkCode()) + // Skip the console path entirely if a GUI wizard is registered — it will + // handle auth after the window opens. + if (!ESHasFirstTimeSetupCallback()) { - // Magic link not available (no credentials file, no tty, or user declined). - // Fall back to API key if one is configured. - const char* envKey = getenv("INFINIDREAM_API_KEY"); - std::string storedKey = g_Settings()->Get("settings.content.api_key", std::string("")); - if (!((envKey && *envKey) || !storedKey.empty())) + if (!EDreamClient::LoginWithMagicLinkCode()) { - fprintf(stderr, - "\nNo sealed session or API key found.\n" - "Options:\n" - " 1. Run interactively to be prompted for your email and log in\n" - " 2. Set INFINIDREAM_API_KEY environment variable\n" - " 3. Run with --cached to play locally cached videos\n"); - return false; + // Magic link not available (no credentials, no tty, or user declined). + // Fall back to API key if one is configured. + const char* envKey = getenv("INFINIDREAM_API_KEY"); + std::string storedKey = g_Settings()->Get("settings.content.api_key", std::string("")); + if (!((envKey && *envKey) || !storedKey.empty())) + { + fprintf(stderr, + "\nNo sealed session or API key found.\n" + "Options:\n" + " 1. Run interactively to be prompted for your email and log in\n" + " 2. Set INFINIDREAM_API_KEY environment variable\n" + " 3. Run with --cached to play locally cached videos\n"); + return false; + } + fprintf(stderr, "Warning: no sealed session token — video downloads may not work with API key only.\n"); } - fprintf(stderr, "Warning: no sealed session token — video downloads may not work with API key only.\n"); + // else: LoginWithMagicLinkCode() succeeded — sealed session now in settings } - // else: LoginWithMagicLinkCode() succeeded — sealed session now in settings } } #endif // LINUX_GNU @@ -1081,6 +1087,7 @@ class CElectricSheep m_SplashFilename = std::string(); m_spCrossFade = nullptr; m_StartupScreen = nullptr; + m_spOSD = nullptr; m_HudManager = nullptr; m_bPaused = false; diff --git a/client_generic/Client/client_linux.h b/client_generic/Client/client_linux.h index 0e92bd1c..449c4308 100644 --- a/client_generic/Client/client_linux.h +++ b/client_generic/Client/client_linux.h @@ -7,6 +7,8 @@ #include "DisplayVulkan.h" #include "Exception.h" +#include "FirstTimeSetupVulkan.h" +#include "SettingsDialogVulkan.h" #include "Log.h" #include "MathBase.h" #include "PlatformUtils.h" @@ -65,6 +67,9 @@ class CElectricSheep_Linux : public CElectricSheep // ); } + FirstTimeSetupVulkan_Register(); + SettingsDialogVulkan_Register(); + if (CElectricSheep::Startup() == false) return false; @@ -85,6 +90,12 @@ class CElectricSheep_Linux : public CElectricSheep if (!spKey->m_bPressed) return true; // swallow all key releases + // Wizard/settings have already consumed this via FeedKey; block game bindings while up. + if (FirstTimeSetupVulkan_IsWizardVisible()) + return true; + if (SettingsDialogVulkan_IsVisible()) + return true; + switch (spKey->m_Code) { // Linux-only: ESC exits fullscreen back to windowed (never quits). @@ -163,6 +174,12 @@ class CElectricSheep_Linux : public CElectricSheep CElectricSheep::HandleOneEvent(spEvent); return true; + // Linux-only: Ctrl+, opens the settings dialog (matches Mac/Windows). + case DisplayOutput::CKeyEvent::KEY_Comma: + if (spKey->m_bCtrl) { ESShowPreferences(); return true; } + CElectricSheep::HandleOneEvent(spEvent); + return true; + // Delegate everything else silently to parent default: CElectricSheep::HandleOneEvent(spEvent); diff --git a/client_generic/ContentDecoder/Clip.cpp b/client_generic/ContentDecoder/Clip.cpp index f6080f3a..34c0ad2f 100644 --- a/client_generic/ContentDecoder/Clip.cpp +++ b/client_generic/ContentDecoder/Clip.cpp @@ -123,6 +123,21 @@ bool CClip::Start(int64_t _seekFrame) void CClip::Stop() { m_spDecoder->Stop(); } +void CClip::ReleaseRenderResources() +{ + if (m_spRenderer) + m_spRenderer->WaitForIdle(); + + // Vulkan textures and command buffers are owned by the frame display. Release + // them while serialized with rendering; decoder teardown may then happen on a + // worker without touching the renderer or its command pool. + m_spFrameDisplay.reset(); + m_spFrameData.reset(); + m_LastValidFrame.reset(); + m_spImageRef.reset(); + m_spRenderer.reset(); +} + bool CClip::Preload(int64_t _seekFrame) { g_Log->Info("Starting preloading %s at frame %d", m_ClipMetadata.path.c_str(), _seekFrame); @@ -150,7 +165,7 @@ bool CClip::IsPreloadComplete() const { // Require a minimum number of frames to consider preloading complete uint32_t queueLength = m_spDecoder->QueueLength(); - uint32_t minFramesRequired = 10; // Require at least 10 frames + uint32_t minFramesRequired = 5; // Match the Rebuffering exit threshold in Update() bool complete = queueLength >= minFramesRequired; @@ -223,8 +238,8 @@ int CClip::GetFramesToAdvance(double _timelineTime, _decoderClock->interframeDelta = _decoderClock->acc / dt; if (framesToAdvance > 1) { - g_Log->Info("Frame timing catch-up: advancing %d frames (deltaTime: %.4f, dt: %.4f)", - framesToAdvance, deltaTime, dt); + g_Log->Debug("Frame timing catch-up: advancing %d frames (deltaTime: %.4f, dt: %.4f)", + framesToAdvance, deltaTime, dt); } return framesToAdvance; diff --git a/client_generic/ContentDecoder/Clip.h b/client_generic/ContentDecoder/Clip.h index 34d8e28a..fd5b3069 100644 --- a/client_generic/ContentDecoder/Clip.h +++ b/client_generic/ContentDecoder/Clip.h @@ -52,7 +52,7 @@ class CClip public: // tmp public for debug sClipMetadata m_ClipMetadata; - DecoderClock m_DecoderClock; + DecoderClock m_DecoderClock{}; private: //tmp // m_spRenderer must be declared before m_spFrameDisplay so that the frame // display (and its CTextureFlatVulkan) is destroyed BEFORE the renderer @@ -66,8 +66,8 @@ class CClip sFrameMetadata m_CurrentFrameMetadata; private: // tmp mutable std::shared_mutex m_CurrentFrameMetadataLock; - double m_StartTime; - double m_EndTime; + double m_StartTime = 0.0; + double m_EndTime = 0.0; boost::atomic m_HasFinished; boost::atomic m_IsFadingOut; public: @@ -108,9 +108,15 @@ class CClip uint32_t _displayHeight); bool Start(int64_t _seekFrame = -1); void Stop(); + // Must be called by the render/update thread before asynchronous teardown. + void ReleaseRenderResources(); bool Preload(int64_t _seekFrame = -1); bool IsPreloadComplete() const; + bool IsPreloadFailed() const + { + return m_spDecoder && m_spDecoder->FailedToOpen(); + } bool StartPlayback(int64_t _seekFrame = -1); diff --git a/client_generic/ContentDecoder/ContentDecoder.cpp b/client_generic/ContentDecoder/ContentDecoder.cpp index a6d613b3..48370f53 100644 --- a/client_generic/ContentDecoder/ContentDecoder.cpp +++ b/client_generic/ContentDecoder/ContentDecoder.cpp @@ -204,6 +204,13 @@ bool CContentDecoder::IsURL(const std::string& path) return path.substr(0, 7) == "http://" || path.substr(0, 8) == "https://"; } +int CContentDecoder::InterruptIO(void* opaque) +{ + auto* decoder = static_cast(opaque); + return decoder && + (decoder->m_bStop.load() || decoder->m_isShuttingDown.load()); +} + #if defined(WIN32) || defined(_WIN32) || defined(_WIN64) // Prefers AV_PIX_FMT_D3D11 when available; falls back to first software format. // Also creates the hw_frames_ctx here because coded_width/coded_height are only @@ -289,7 +296,9 @@ bool CContentDecoder::Open() } // For URLs, we use avio_open2 - ret = avio_open2(&m_pIOContext, _filename.c_str(), AVIO_FLAG_READ, nullptr, nullptr); + AVIOInterruptCB interruptCallback{&CContentDecoder::InterruptIO, this}; + ret = avio_open2(&m_pIOContext, _filename.c_str(), AVIO_FLAG_READ, + &interruptCallback, nullptr); if (ret < 0) { g_Log->Warning("Failed to open URL %s...", _filename.c_str()); @@ -314,6 +323,12 @@ bool CContentDecoder::Open() } ovi->m_pFormatContext = avformat_alloc_context(); + if (!ovi->m_pFormatContext) + { + g_Log->Error("Failed to allocate format context for %s", _filename.c_str()); + return false; + } + ovi->m_pFormatContext->interrupt_callback = interruptCallback; ovi->m_pFormatContext->pb = custom_io; ovi->m_pFormatContext->flags |= AVFMT_FLAG_CUSTOM_IO; } else { @@ -595,12 +610,12 @@ CVideoFrame* CContentDecoder::ReadOneFrame() sOpenVideoInfo* ovi = m_CurrentVideoInfo.get(); if (ovi == nullptr) return nullptr; - + AVFormatContext* pFormatContext = ovi->m_pFormatContext; - + if (!pFormatContext) return nullptr; - + AVRational timeBase = pFormatContext->streams[ovi->m_VideoStreamID]->time_base; double frameRate = av_q2d( @@ -608,6 +623,8 @@ CVideoFrame* CContentDecoder::ReadOneFrame() AVPacket* packet; AVPacket* filteredPacket; int frameDecoded = 0; + int eagainCount = 0; + auto readOneFrameStart = std::chrono::steady_clock::now(); AVFrame* pFrame = ovi->m_pFrame; AVCodecContext* pVideoCodecContext = ovi->m_pVideoCodecContext; CVideoFrame* pVideoFrame = nullptr; @@ -622,7 +639,17 @@ CVideoFrame* CContentDecoder::ReadOneFrame() bool endOfPackets = false; if (!ovi->m_ReadingTrailingFrames) { - if (av_read_frame(pFormatContext, packet) < 0) + auto t0 = std::chrono::steady_clock::now(); + int avrf_ret = av_read_frame(pFormatContext, packet); + auto avrfMs = std::chrono::duration_cast( + std::chrono::steady_clock::now() - t0).count(); + if (avrfMs > 500) { + g_Log->Warning("ReadOneFrame: av_read_frame SLOW (%lldms) ret=%d seekTarget=%lld frame=%lld", + (long long)avrfMs, avrf_ret, + (long long)ovi->m_SeekTargetFrame, + (long long)ovi->m_CurrentFrameIndex); + } + if (avrf_ret < 0) { // Reached end of packets, now flush decoder ovi->m_ReadingTrailingFrames = true; @@ -721,13 +748,22 @@ CVideoFrame* CContentDecoder::ReadOneFrame() { av_packet_free(&packet); av_packet_free(&filteredPacket); - + if (endOfPackets) { // If we're flushing and get EAGAIN, we're done m_HasEnded.exchange(true); return nullptr; } - + + eagainCount++; + if (eagainCount == 50 || eagainCount % 500 == 0) { + auto eagainMs = std::chrono::duration_cast( + std::chrono::steady_clock::now() - readOneFrameStart).count(); + g_Log->Warning("ReadOneFrame: stuck in EAGAIN loop — count=%d elapsed=%lldms seekTarget=%lld frame=%lld", + eagainCount, (long long)eagainMs, + (long long)ovi->m_SeekTargetFrame, + (long long)ovi->m_CurrentFrameIndex); + } std::this_thread::sleep_for(std::chrono::milliseconds(10)); continue; } @@ -830,6 +866,7 @@ CVideoFrame* CContentDecoder::ReadOneFrame() { if (ovi->m_CurrentFrameIndex >= ovi->m_SeekTargetFrame) { + ovi->m_SeekTargetFrame = -1; // resume normal playback; prevents re-seek loop break; } else @@ -976,11 +1013,58 @@ CVideoFrame* CContentDecoder::ReadOneFrame() av_packet_free(&packet); av_packet_free(&filteredPacket); - + + auto totalMs = std::chrono::duration_cast( + std::chrono::steady_clock::now() - readOneFrameStart).count(); + if (totalMs > 2000) { + g_Log->Warning("ReadOneFrame: completed in %lldms (EAGAIN×%d) seekTarget=%lld frame=%lld", + (long long)totalMs, eagainCount, + (long long)ovi->m_SeekTargetFrame, + (long long)ovi->m_CurrentFrameIndex); + } + return pVideoFrame; } // MARK: Read Frames Thread +void CContentDecoder::OpenAndReadFramesThread() +{ + PlatformUtils::SetThreadName("OpenVideo"); + + try + { + if (m_bStop.load() || m_isShuttingDown.load()) + { + m_HasEnded.store(true); + return; + } + + if (!Open()) + { + m_OpenFailed.store(true); + m_HasEnded.store(true); + g_Log->Error("Asynchronous decoder open failed for %s", + m_Metadata.dreamData.uuid.c_str()); + return; + } + + if (m_bStop.load() || m_isShuttingDown.load()) + { + m_HasEnded.store(true); + return; + } + + ReadFramesThread(); + } + catch (const std::exception& e) + { + m_OpenFailed.store(true); + m_HasEnded.store(true); + g_Log->Error("Exception while opening decoder for %s: %s", + m_Metadata.dreamData.uuid.c_str(), e.what()); + } +} + void CContentDecoder::ReadFramesThread() { try @@ -1142,6 +1226,8 @@ void CContentDecoder::ReadFramesThread() #else avcodec_flush_buffers(m_CurrentVideoInfo->m_pVideoCodecContext); #endif + if (m_CurrentVideoInfo->m_pBsfContext) + av_bsf_flush(m_CurrentVideoInfo->m_pBsfContext); if (seek < 0) { @@ -1259,14 +1345,32 @@ bool CContentDecoder::Start(const sClipMetadata& metadata, int64_t _seekFrame) m_CurrentVideoInfo->m_Path = metadata.path; m_CurrentVideoInfo->m_SeekTargetFrame = _seekFrame; m_HasEnded.exchange(false); - - if (!Open()) - return false; - - // Start by opening, so we have a context to work with. + m_OpenFailed.store(false); m_bStop = false; - m_pDecoderThread = - new thread(bind(&CContentDecoder::ReadFramesThread, this)); + + // Remote demuxer initialization can spend seconds in avio_open2, + // avformat_open_input, or avformat_find_stream_info. Keep that work off the + // player/render thread so the outgoing clip remains responsive. Local files + // retain synchronous validation so callers still get immediate failures for + // missing or corrupt cache entries. + if (IsURL(metadata.path)) + { + g_Log->Info("Opening remote decoder asynchronously for %s", + metadata.dreamData.uuid.c_str()); + m_pDecoderThread = + new thread(bind(&CContentDecoder::OpenAndReadFramesThread, this)); + } + else + { + if (!Open()) + { + m_OpenFailed.store(true); + m_HasEnded.store(true); + return false; + } + m_pDecoderThread = + new thread(bind(&CContentDecoder::ReadFramesThread, this)); + } return true; } diff --git a/client_generic/ContentDecoder/ContentDecoder.h b/client_generic/ContentDecoder/ContentDecoder.h index 98166dc3..f85abf65 100644 --- a/client_generic/ContentDecoder/ContentDecoder.h +++ b/client_generic/ContentDecoder/ContentDecoder.h @@ -113,7 +113,7 @@ struct sOpenVideoInfo */ class CContentDecoder { - bool m_bStop; + std::atomic m_bStop{true}; SwsContext* m_pScaler; uint32_t m_ScalerWidth; @@ -121,6 +121,7 @@ class CContentDecoder boost::thread* m_pDecoderThread; void ReadFramesThread(); + void OpenAndReadFramesThread(); // Queue for decoded frames. Base::CBlockingQueue m_FrameQueue; @@ -132,9 +133,11 @@ class CContentDecoder std::atomic m_SkipToFrame{-1}; // The displayed frame index to use as base for skip boost::atomic m_HasStarted{false}; boost::atomic m_HasEnded; + std::atomic m_OpenFailed{false}; void Destroy(); bool Open(); + static int InterruptIO(void* opaque); CVideoFrame* ReadOneFrame(); static int DumpError(int _err); @@ -165,7 +168,8 @@ class CContentDecoder spCVideoFrame PopVideoFrame(); bool HasEnded() const { return m_HasEnded.load() && !m_FrameQueue.size(); } bool DecoderThreadEnded() const { return m_HasEnded.load(); } // True when decoder thread finished, regardless of queue state - bool Stopped() { return m_bStop; }; + bool Stopped() const { return m_bStop.load(); }; + bool FailedToOpen() const { return m_OpenFailed.load(); } uint32_t QueueLength(); void ClearQueue(uint32_t leave = 0); void SkipTime(float _secondsForward, int64_t _displayedFrameIdx = -1) diff --git a/client_generic/ContentDownloader/CacheManager.cpp b/client_generic/ContentDownloader/CacheManager.cpp index bdacf40b..6bb9599b 100644 --- a/client_generic/ContentDownloader/CacheManager.cpp +++ b/client_generic/ContentDownloader/CacheManager.cpp @@ -430,7 +430,7 @@ void CacheManager::cacheAndPlayImmediately(const std::string& uuid) { auto future = g_ContentDownloader().m_gDownloader.DownloadImmediately(uuid, [](bool success, const std::string& uuid) { if (success) { g_Log->Info("Immediate download completed successfully for UUID: ", uuid.c_str()); - g_Player().PlayDreamNow(uuid, -1); + g_Player().EnqueuePlayDream(uuid, -1); } else { g_Log->Error("Immediate download failed for UUID: ", uuid.c_str()); } diff --git a/client_generic/ContentDownloader/DreamDownloader.cpp b/client_generic/ContentDownloader/DreamDownloader.cpp index e435e548..e711ad82 100644 --- a/client_generic/ContentDownloader/DreamDownloader.cpp +++ b/client_generic/ContentDownloader/DreamDownloader.cpp @@ -91,7 +91,8 @@ void DreamDownloader::FindDreamsThread() { // Minimum space in cache/quota to consider downloading (100 MB) std::uintmax_t minSpaceForDream = (std::uintmax_t)1024 * 1024 * 100; - bool quotaWarningLogged = false; + bool quotaWarningLogged = false; + bool allCachedWarningLogged = false; while (isRunning.load()) { //g_Log->Info("Searching for dreams to download..."); @@ -132,9 +133,21 @@ void DreamDownloader::FindDreamsThread() { // First check if there's a dream to download auto nextDream = GetNextDreamToDownload(); if (!nextDream.has_value()) { - // No more uncached dreams to download + if (!allCachedWarningLogged) { + auto& pm = g_Player().GetPlaylistManager(); + g_Log->Warning( + "All %zu dreams in playlist \"%s\" are already cached — " + "nothing to download. If unexpected, check your server-side " + "playlist assignment (remaining quota: %.2f GB).", + pm.getPlaylistSize(), + pm.getPlaylistName().c_str(), + static_cast(cm.getRemainingQuota()) / (1024.0 * 1024.0 * 1024.0)); + allCachedWarningLogged = true; + } + SetDownloadStatus("All playlist dreams cached"); break; } + allCachedWarningLogged = false; // Preflight checks - only clean cache if we have something to download @@ -200,6 +213,13 @@ void DreamDownloader::FindDreamsThread() { std::lock_guard lock(m_downloadingMutex); m_currentlyDownloading.reset(); } + + // A transient network failure otherwise selects the same + // uncached successor again immediately and spins at thousands + // of requests per second (especially after shutdown aborts I/O). + if (!isRunning.load()) + break; + boost::this_thread::sleep_for(boost::chrono::seconds(1)); } } diff --git a/client_generic/ContentDownloader/PlaylistManager.cpp b/client_generic/ContentDownloader/PlaylistManager.cpp index 8557ede5..724298c0 100644 --- a/client_generic/ContentDownloader/PlaylistManager.cpp +++ b/client_generic/ContentDownloader/PlaylistManager.cpp @@ -14,7 +14,7 @@ #include PlaylistManager::PlaylistManager() -: m_started(false), m_playbackMode(PlaybackMode::Normal), m_offlineMode(false), m_currentPosition(0), m_cacheManager(Cache::CacheManager::getInstance()), m_shouldTerminate(false) { +: m_started(false), m_playbackMode(PlaybackMode::Normal), m_offlineMode(false), m_currentPosition(0), m_cacheManager(Cache::CacheManager::getInstance()), m_isCheckingActive(false), m_shouldTerminate(false) { } PlaylistManager::~PlaylistManager() { @@ -22,78 +22,87 @@ PlaylistManager::~PlaylistManager() { } void PlaylistManager::setOfflineMode(bool offline) { + std::lock_guard lock(m_stateMutex); m_offlineMode = offline; } std::string PlaylistManager::getCurrentDreamUUID() const { - std::lock_guard lock(m_stateMutex); + std::lock_guard lock(m_stateMutex); return m_currentDreamUUID; } size_t PlaylistManager::getCurrentPosition() const { - std::lock_guard lock(m_stateMutex); + std::lock_guard lock(m_stateMutex); return m_currentPosition; } void PlaylistManager::setPlaybackMode(PlaybackMode mode) { - std::lock_guard lock(m_stateMutex); + std::lock_guard lock(m_stateMutex); m_playbackMode = mode; } PlaybackMode PlaylistManager::getPlaybackMode() const { - std::lock_guard lock(m_stateMutex); + std::lock_guard lock(m_stateMutex); return m_playbackMode; } // MARK: Init bool PlaylistManager::initializePlaylist(const std::string& playlistUUID, bool fetchPlaylist = true) { - m_initializeInProgress = true; - m_currentPlaylistUUID = playlistUUID; + uint64_t generation = 0; + bool offline = false; + { + std::lock_guard lock(m_stateMutex); + m_initializeInProgress = true; + m_currentPlaylistUUID = playlistUUID; + generation = ++m_playlistGeneration; + offline = m_offlineMode; + } - if (!m_offlineMode && fetchPlaylist) { + if (!offline && fetchPlaylist) { if (!EDreamClient::FetchPlaylist(playlistUUID)) { g_Log->Error("Failed to fetch playlist. UUID: %s", playlistUUID.c_str()); + std::lock_guard lock(m_stateMutex); m_initializeInProgress = false; return false; } } - if (!parsePlaylist(playlistUUID)) { - if (m_offlineMode) { + if (!parsePlaylist(playlistUUID, generation)) { + if (offline) { g_Log->Warning("Failed to parse playlist in offline mode. Falling back to offline playlist."); initializeOfflinePlaylist(); } else { + std::lock_guard lock(m_stateMutex); m_initializeInProgress = false; return false; } } - if (m_playlist.empty() && m_offlineMode) { - g_Log->Warning("Parsed playlist is empty in offline mode. Falling back to offline playlist."); - initializeOfflinePlaylist(); - } - - m_currentPosition = 0; - m_started = false; { - std::lock_guard lock(m_stateMutex); + std::lock_guard lock(m_stateMutex); + if (m_playlist.empty() && m_offlineMode) { + g_Log->Warning("Parsed playlist is empty in offline mode. Falling back to offline playlist."); + initializeOfflinePlaylist(); + } + + m_currentPosition = 0; + m_started = false; m_currentDreamUUID = m_playlist.empty() ? "" : m_playlist[0].uuid; + resetPlayHistory(); + m_initializeInProgress = false; } - // Clear play history when switching playlists to prevent out-of-bounds access - resetPlayHistory(); - // Start periodic checking if it's not already running. Don't in offline mode though! - if (!m_isCheckingActive && !m_offlineMode) { + if (!m_isCheckingActive && !offline) { startPeriodicChecking(); } - m_initializeInProgress = false; return true; } void PlaylistManager::initializeOfflinePlaylist() { + std::lock_guard lock(m_stateMutex); g_Log->Info("Initializing offline playlist"); m_playlist.clear(); Cache::CacheManager& cm = Cache::CacheManager::getInstance(); @@ -112,7 +121,7 @@ void PlaylistManager::initializeOfflinePlaylist() { m_currentPosition = 0; m_started = false; { - std::lock_guard lock(m_stateMutex); + std::lock_guard lock(m_stateMutex); m_currentDreamUUID = m_playlist.empty() ? "" : m_playlist[0].uuid; } @@ -130,6 +139,7 @@ void PlaylistManager::initializeOfflinePlaylist() { std::vector PlaylistManager::getCurrentPlaylistUUIDs() const { + std::lock_guard lock(m_stateMutex); std::vector uuids; uuids.reserve(m_playlist.size()); for (const auto& entry : m_playlist) { @@ -138,7 +148,11 @@ std::vector PlaylistManager::getCurrentPlaylistUUIDs() const { return uuids; } -bool PlaylistManager::parsePlaylist(const std::string& playlistUUID) { +bool PlaylistManager::parsePlaylist(const std::string& playlistUUID, + uint64_t expectedGeneration, + bool preserveCurrent, + const std::string& currentDreamUUID, + size_t oldPosition) { // Parse the playlist std::vector entries = EDreamClient::ParsePlaylist(playlistUUID); @@ -150,31 +164,59 @@ bool PlaylistManager::parsePlaylist(const std::string& playlistUUID) { // Get the playlist metadata auto [playlistName, playlistArtist, isNSFW, timestamp, loops] = EDreamClient::ParsePlaylistMetadata(playlistUUID); - // Filter out evicted UUIDs and unprocessed dreams - if (!m_offlineMode) { - m_playlist = filterActiveAndProcessedDreams(entries); - } else { - m_playlist = filterUncachedDreams(entries); + bool offline = false; + { + std::lock_guard lock(m_stateMutex); + offline = m_offlineMode; } + auto filtered = offline ? filterUncachedDreams(entries) + : filterActiveAndProcessedDreams(entries); - - // Update member variables - m_currentPlaylistName = playlistName; - m_currentPlaylistArtist = playlistArtist; - m_isPlaylistNSFW = isNSFW; - m_playlistTimestamp = timestamp; + { + std::lock_guard lock(m_stateMutex); + if (m_playlistGeneration != expectedGeneration || + m_currentPlaylistUUID != playlistUUID) { + g_Log->Info("Discarding stale playlist parse for %s", playlistUUID.c_str()); + return false; + } - // Update loop iterations from server-provided value and reset counter if it changed - if (loops != m_loopIterations) { - m_currentLoopCount = 0; - } - m_loopIterations = loops; - g_Log->Info("Playlist loops: %d", m_loopIterations); + if (preserveCurrent && filtered.empty()) + return false; + + size_t preservedPosition = 0; + if (preserveCurrent) + { + const auto it = std::find_if(filtered.begin(), filtered.end(), + [¤tDreamUUID](const PlaylistEntry& entry) { + return entry.uuid == currentDreamUUID; + }); + preservedPosition = it != filtered.end() + ? static_cast(std::distance(filtered.begin(), it)) + : std::min(oldPosition, filtered.size() - 1); + } + + m_playlist = std::move(filtered); + m_currentPlaylistName = playlistName; + m_currentPlaylistArtist = playlistArtist; + m_isPlaylistNSFW = isNSFW; + m_playlistTimestamp = timestamp; - g_Log->Info("Updated playlist: %s by %s (UUID: %s, NSFW: %s, Timestamp: %lld) with %zu dreams", - m_currentPlaylistName.c_str(), m_currentPlaylistArtist.c_str(), - m_currentPlaylistUUID.c_str(), m_isPlaylistNSFW ? "Yes" : "No", - m_playlistTimestamp, m_playlist.size()); + if (loops != m_loopIterations) + m_currentLoopCount = 0; + m_loopIterations = loops; + + if (preserveCurrent) + { + m_currentPosition = preservedPosition; + m_currentDreamUUID = m_playlist[m_currentPosition].uuid; + } + + g_Log->Info("Playlist loops: %d", m_loopIterations); + g_Log->Info("Updated playlist: %s by %s (UUID: %s, NSFW: %s, Timestamp: %lld) with %zu dreams", + m_currentPlaylistName.c_str(), m_currentPlaylistArtist.c_str(), + m_currentPlaylistUUID.c_str(), m_isPlaylistNSFW ? "Yes" : "No", + m_playlistTimestamp, m_playlist.size()); + } return true; } @@ -204,6 +246,7 @@ std::vector PlaylistManager::filterUncachedDreams(const std::vect } size_t PlaylistManager::countCachedDreamsAhead() const { + std::lock_guard lock(m_stateMutex); if (m_playlist.empty() || !m_started) { return 0; } @@ -229,6 +272,7 @@ size_t PlaylistManager::countCachedDreamsAhead() const { bool PlaylistManager::hasKeyframes() const { + std::lock_guard lock(m_stateMutex); // Check if any dream in the playlist has keyframes (start or end) for (const auto& entry : m_playlist) { if (entry.startKeyframe.has_value() || entry.endKeyframe.has_value()) { @@ -240,6 +284,7 @@ bool PlaylistManager::hasKeyframes() const { std::optional PlaylistManager::getNextUncachedDream() const { + std::lock_guard lock(m_stateMutex); Cache::CacheManager& cm = Cache::CacheManager::getInstance(); auto& downloader = g_ContentDownloader().m_gDownloader; @@ -499,6 +544,7 @@ bool PlaylistManager::isDreamProcessed(const std::string& uuid) const { } void PlaylistManager::removeCurrentDream() { + std::lock_guard lock(m_stateMutex); if (m_playlist.empty()) { return; // Nothing to remove } @@ -537,6 +583,7 @@ bool PlaylistManager::isLoopingDream(const PlaylistEntry& entry) const { } std::optional PlaylistManager::findKeyframeMatch(const PlaylistEntry& currentEntry, bool canStream) const { + std::lock_guard lock(m_stateMutex); if (!currentEntry.endKeyframe) { return std::nullopt; } @@ -563,6 +610,7 @@ std::optional PlaylistManager::findKeyframeMatch(const PlaylistEntry& cu } std::optional PlaylistManager::preflightNextDream(bool canStream, bool forceNext) const { + std::lock_guard lock(m_stateMutex); g_Log->Info("Preflight : start (mode: %d, m_started: %d, m_currentPosition: %zu, forceNext: %d)", static_cast(m_playbackMode), m_started, m_currentPosition, forceNext); @@ -883,6 +931,7 @@ std::optional PlaylistManager::preflightNext } std::shared_ptr PlaylistManager::moveToNextDream(const NextDreamDecision& decision) { + std::lock_guard lock(m_stateMutex); if (m_playlist.empty()) { return nullptr; } @@ -955,7 +1004,7 @@ std::shared_ptr PlaylistManager::moveToNextDream(const NextD // Update current dream info { - std::lock_guard lock(m_stateMutex); + std::lock_guard lock(m_stateMutex); m_currentDreamUUID = m_playlist[m_currentPosition].uuid; } m_currentDream = decision.dream; @@ -969,6 +1018,7 @@ std::shared_ptr PlaylistManager::moveToNextDream(const NextD std::optional PlaylistManager::getDreamByUUID(const std::string& dreamUUID) { + std::lock_guard lock(m_stateMutex); auto it = std::find_if(m_playlist.begin(), m_playlist.end(), [&dreamUUID](const PlaylistEntry& entry) { return entry.uuid == dreamUUID; @@ -985,7 +1035,7 @@ std::optional PlaylistManager::getDreamByUUI // Dream is in the playlist, update the position m_currentPosition = std::distance(m_playlist.begin(), it); { - std::lock_guard lock(m_stateMutex); + std::lock_guard lock(m_stateMutex); m_currentDreamUUID = dreamUUID; } m_currentDream = m_cacheManager.getDream(dreamUUID); @@ -1004,6 +1054,7 @@ std::optional PlaylistManager::getDreamByUUI } std::shared_ptr PlaylistManager::getPreviousDream() { + std::lock_guard lock(m_stateMutex); if (m_playlist.empty()) { return nullptr; } @@ -1050,7 +1101,7 @@ std::shared_ptr PlaylistManager::getPreviousDream() { } { - std::lock_guard lock(m_stateMutex); + std::lock_guard lock(m_stateMutex); m_currentDreamUUID = m_playlist[m_currentPosition].uuid; } m_currentDream = m_cacheManager.getDream(m_currentDreamUUID); @@ -1061,10 +1112,12 @@ std::shared_ptr PlaylistManager::getPreviousDream() { } bool PlaylistManager::hasMoreDreams() const { + std::lock_guard lock(m_stateMutex); return !m_playlist.empty(); } std::shared_ptr PlaylistManager::getCurrentDream() const { + std::lock_guard lock(m_stateMutex); if (m_playlist.empty()) { return nullptr; } @@ -1073,38 +1126,43 @@ std::shared_ptr PlaylistManager::getCurrentDream() const { } void PlaylistManager::setCurrentPosition(size_t position) { + std::lock_guard lock(m_stateMutex); if (position < m_playlist.size()) { m_currentPosition = position; } } std::string PlaylistManager::getPlaylistName() const { + std::lock_guard lock(m_stateMutex); return m_currentPlaylistName.empty() ? "No playlist loaded" : m_currentPlaylistName; } std::string PlaylistManager::getPlaylistUUID() const { - std::lock_guard lock(m_stateMutex); + std::lock_guard lock(m_stateMutex); return m_currentPlaylistUUID; } size_t PlaylistManager::getPlaylistSize() const { - std::lock_guard lock(m_stateMutex); + std::lock_guard lock(m_stateMutex); return m_playlist.size(); } void PlaylistManager::clearPlaylist() { + std::lock_guard lock(m_stateMutex); m_playlist.clear(); m_currentPosition = 0; } // maybe someday? void PlaylistManager::shufflePlaylist() { + std::lock_guard lock(m_stateMutex); auto rng = std::default_random_engine {}; std::shuffle(std::begin(m_playlist), std::end(m_playlist), rng); m_currentPosition = 0; } std::tuple PlaylistManager::getPlaylistInfo() const { + std::lock_guard lock(m_stateMutex); return {m_currentPlaylistName, m_currentPlaylistArtist, m_isPlaylistNSFW, m_playlistTimestamp, m_loopIterations}; } @@ -1158,24 +1216,25 @@ void PlaylistManager::updateNextCheckTime() { void PlaylistManager::periodicCheckThread() { PlatformUtils::SetThreadName("PeriodicPlaylistCheck"); - while (!m_shouldTerminate) { + while (!m_shouldTerminate.load()) { updateNextCheckTime(); { std::unique_lock lock(m_cvMutex); - if (m_cv.wait_for(lock, m_checkInterval, [this] { return m_shouldTerminate; })) { + if (m_cv.wait_for(lock, m_checkInterval, + [this] { return m_shouldTerminate.load(); })) { // If m_shouldTerminate is true, exit the thread break; } } - if (!m_isCheckingActive.load() || m_shouldTerminate) { + if (!m_isCheckingActive.load() || m_shouldTerminate.load()) { break; } checkForPlaylistChanges(); // Check again after network call in case termination was requested during it - if (m_shouldTerminate) { + if (m_shouldTerminate.load()) { g_Log->Info("PlaylistManager::periodicCheckThread() - termination requested after check"); break; } @@ -1184,24 +1243,41 @@ void PlaylistManager::periodicCheckThread() { } bool PlaylistManager::checkForPlaylistChanges() { + std::string playlistUUID; + int64_t oldTimestamp = 0; + { + std::lock_guard lock(m_stateMutex); + playlistUUID = m_currentPlaylistUUID; + oldTimestamp = m_playlistTimestamp; + } + // First, fetch the playlist to ensure we have the latest version - if (!EDreamClient::FetchPlaylist(m_currentPlaylistUUID)) { - g_Log->Error("Failed to fetch playlist for checking changes. UUID: %s", m_currentPlaylistUUID.c_str()); + if (!EDreamClient::FetchPlaylist(playlistUUID)) { + g_Log->Error("Failed to fetch playlist for checking changes. UUID: %s", playlistUUID.c_str()); return false; } // Then parse the metadata to get the new timestamp - auto [newName, newArtist, newNSFW, newTimestamp, newLoops] = EDreamClient::ParsePlaylistMetadata(m_currentPlaylistUUID); + auto [newName, newArtist, newNSFW, newTimestamp, newLoops] = EDreamClient::ParsePlaylistMetadata(playlistUUID); g_Log->Info("Old timestamp: %lld, New timestamp: %lld", - m_playlistTimestamp, newTimestamp); + oldTimestamp, newTimestamp); - if (newTimestamp > m_playlistTimestamp) { + if (newTimestamp > oldTimestamp) { g_Log->Info("Playlist change detected. Old timestamp: %lld, New timestamp: %lld", - m_playlistTimestamp, newTimestamp); + oldTimestamp, newTimestamp); + + { + std::lock_guard lock(m_stateMutex); + if (m_currentPlaylistUUID != playlistUUID) { + g_Log->Info("Ignoring periodic update for stale playlist %s", + playlistUUID.c_str()); + return false; + } + } // Update the playlist - if (updatePlaylist()) { + if (updatePlaylist(true)) { g_Log->Info("Playlist updated successfully"); return true; } else { @@ -1215,39 +1291,32 @@ bool PlaylistManager::checkForPlaylistChanges() { } bool PlaylistManager::updatePlaylist(bool alreadyFetched) { - std::string currentDreamUUID = getCurrentDreamUUID(); - size_t oldPosition = m_currentPosition; + std::string currentDreamUUID; + std::string playlistUUID; + size_t oldPosition = 0; + uint64_t generation = 0; + { + std::lock_guard lock(m_stateMutex); + currentDreamUUID = m_currentDreamUUID; + playlistUUID = m_currentPlaylistUUID; + oldPosition = m_currentPosition; + generation = m_playlistGeneration; + } if (!alreadyFetched) { - if (!EDreamClient::FetchPlaylist(m_currentPlaylistUUID)) { - g_Log->Error("Failed to fetch playlist. UUID: %s", m_currentPlaylistUUID.c_str()); + if (!EDreamClient::FetchPlaylist(playlistUUID)) { + g_Log->Error("Failed to fetch playlist. UUID: %s", playlistUUID.c_str()); return false; } } - if (!parsePlaylist(m_currentPlaylistUUID)) { + if (!parsePlaylist(playlistUUID, generation, true, + currentDreamUUID, oldPosition)) { return false; } - // Try to find the position of the current dream in the updated playlist - size_t newPosition = findPositionOfDream(currentDreamUUID); - - if (newPosition != std::string::npos) { - // If found, update the current position - m_currentPosition = newPosition; - } else { - // If not found, try to keep a similar relative position - m_currentPosition = std::min(oldPosition, m_playlist.size() - 1); - } - - // Update the current dream UUID - { - std::lock_guard lock(m_stateMutex); - m_currentDreamUUID = m_playlist[m_currentPosition].uuid; - } - - g_Log->Info("Playlist updated. New position: %zu, Current dream UUID: %s (old position: %zu, old uuid : %s)", - m_currentPosition, m_currentDreamUUID.c_str(), oldPosition, currentDreamUUID.c_str()); + g_Log->Info("Playlist updated while preserving dream %s at prior position %zu", + currentDreamUUID.c_str(), oldPosition); return true; } @@ -1299,4 +1368,3 @@ size_t PlaylistManager::findFirstUnplayedPosition() const { } return 0; // Return 0 if all dreams played } - diff --git a/client_generic/ContentDownloader/PlaylistManager.h b/client_generic/ContentDownloader/PlaylistManager.h index cc3c0d7b..4a06efdb 100644 --- a/client_generic/ContentDownloader/PlaylistManager.h +++ b/client_generic/ContentDownloader/PlaylistManager.h @@ -9,6 +9,7 @@ #define PLAYLIST_MANAGER_H #include +#include #include #include #include @@ -61,7 +62,10 @@ class PlaylistManager { bool initializePlaylist(const std::string& playlistUUID, bool fetchPlaylist); void setOfflineMode(bool offline); - bool isOfflineMode() const { return m_offlineMode; } + bool isOfflineMode() const { + std::lock_guard lock(m_stateMutex); + return m_offlineMode; + } std::vector getCurrentPlaylistUUIDs() const; @@ -75,8 +79,14 @@ class PlaylistManager { size_t countCachedDreamsAhead() const; // Get the lookahead limit for downloading - size_t getDownloadLookaheadLimit() const { return m_downloadLookaheadLimit; } - void setDownloadLookaheadLimit(size_t limit) { m_downloadLookaheadLimit = limit; } + size_t getDownloadLookaheadLimit() const { + std::lock_guard lock(m_stateMutex); + return m_downloadLookaheadLimit; + } + void setDownloadLookaheadLimit(size_t limit) { + std::lock_guard lock(m_stateMutex); + m_downloadLookaheadLimit = limit; + } // Check if any dream in the playlist has keyframes bool hasKeyframes() const; @@ -123,9 +133,18 @@ class PlaylistManager { bool isLoopingDream(const PlaylistEntry& entry) const; // Loop iteration control - int getLoopIterations() const { return m_loopIterations; } - void setLoopIterations(int iterations) { m_loopIterations = iterations; } - int getCurrentLoopCount() const { return m_currentLoopCount; } + int getLoopIterations() const { + std::lock_guard lock(m_stateMutex); + return m_loopIterations; + } + void setLoopIterations(int iterations) { + std::lock_guard lock(m_stateMutex); + m_loopIterations = iterations; + } + int getCurrentLoopCount() const { + std::lock_guard lock(m_stateMutex); + return m_currentLoopCount; + } std::string getCurrentDreamUUID() const; // Actually move to the next dream based on preflight decision @@ -168,8 +187,14 @@ class PlaylistManager { // Get various metadata of the current playlist std::string getPlaylistName() const; std::string getPlaylistUUID() const; - bool isPlaylistNSFW() const { return m_isPlaylistNSFW; } - int64_t getPlaylistTimestamp() const { return m_playlistTimestamp; } + bool isPlaylistNSFW() const { + std::lock_guard lock(m_stateMutex); + return m_isPlaylistNSFW; + } + int64_t getPlaylistTimestamp() const { + std::lock_guard lock(m_stateMutex); + return m_playlistTimestamp; + } // Clear the current playlist void clearPlaylist(); @@ -185,7 +210,10 @@ class PlaylistManager { std::chrono::seconds getTimeUntilNextCheck() const; void removeCurrentDream(); - bool isReady() { return !m_initializeInProgress; }; + bool isReady() const { + std::lock_guard lock(m_stateMutex); + return !m_initializeInProgress; + }; private: std::vector m_playlist; @@ -204,7 +232,7 @@ class PlaylistManager { size_t m_currentPosition; std::shared_ptr m_currentDream; std::string m_currentDreamUUID; // Store the UUID of the currently playing dream - mutable std::mutex m_stateMutex; + mutable std::recursive_mutex m_stateMutex; Cache::CacheManager& m_cacheManager; @@ -214,7 +242,7 @@ class PlaylistManager { bool isDreamProcessed(const std::string& uuid) const; std::atomic m_isCheckingActive; - bool m_shouldTerminate; + std::atomic m_shouldTerminate; std::thread m_checkingThread; std::chrono::minutes m_checkInterval{60}; @@ -227,7 +255,10 @@ class PlaylistManager { bool checkForPlaylistChanges(); bool updatePlaylist(bool alreadyFetched = false); - bool parsePlaylist(const std::string& playlistUUID); + bool parsePlaylist(const std::string& playlistUUID, uint64_t expectedGeneration, + bool preserveCurrent = false, + const std::string& currentDreamUUID = {}, + size_t oldPosition = 0); size_t findPositionOfDream(const std::string& dreamUUID) const; std::atomic m_nextCheckTime; @@ -252,6 +283,7 @@ class PlaylistManager { // Download lookahead configuration size_t m_downloadLookaheadLimit = 5; + uint64_t m_playlistGeneration = 0; }; diff --git a/client_generic/DisplayOutput/DisplayOutput.h b/client_generic/DisplayOutput/DisplayOutput.h index 22f54440..077dae76 100644 --- a/client_generic/DisplayOutput/DisplayOutput.h +++ b/client_generic/DisplayOutput/DisplayOutput.h @@ -266,6 +266,10 @@ class CDisplayOutput float Aspect() { return ((float)m_Height / (float)m_Width); }; bool Closed() { return (m_bClosed); }; void Close() { m_bClosed = true; }; + // Allow the renderer to sync tracked dimensions after a swapchain recreation + // where the Vulkan driver enforced a size (via currentExtent) that differs + // from what ConfigureNotify last reported. + void SyncSize(uint32_t w, uint32_t h) { m_Width = w; m_Height = h; }; }; MakeSmartPointers(CDisplayOutput); diff --git a/client_generic/DisplayOutput/Renderer/Renderer.h b/client_generic/DisplayOutput/Renderer/Renderer.h index 83d3edfc..2b4ac4eb 100644 --- a/client_generic/DisplayOutput/Renderer/Renderer.h +++ b/client_generic/DisplayOutput/Renderer/Renderer.h @@ -147,8 +147,10 @@ class CRenderer spCDisplayOutput Display() { return m_spDisplay; }; // - virtual bool BeginFrame(void) { return (true); }; - virtual bool EndFrame(bool drawn = true) { return (drawn); }; + virtual bool BeginFrame(void) { return (true); }; + virtual bool EndFrame(bool drawn = true) { return (drawn); }; + // Synchronize outstanding GPU work before render-owned resources are released. + virtual void WaitForIdle() {}; // Textures. virtual spCTextureFlat diff --git a/client_generic/DisplayOutput/Vulkan/DisplayVulkan.cpp b/client_generic/DisplayOutput/Vulkan/DisplayVulkan.cpp index 4d40c127..f919dc79 100644 --- a/client_generic/DisplayOutput/Vulkan/DisplayVulkan.cpp +++ b/client_generic/DisplayOutput/Vulkan/DisplayVulkan.cpp @@ -3,6 +3,8 @@ #include "DisplayVulkan.h" #include "PlatformUtils_Internal.h" #include "Log.h" +#include "FirstTimeSetupVulkan.h" +#include "SettingsDialogVulkan.h" #include #include @@ -103,6 +105,58 @@ void CDisplayVulkan::onLibdecorCommit(struct libdecor_frame*, void* data) } #endif +// wl_output listener — derives UI scale from physical DPI, same method as the +// X11 path. Integer wl_output.scale is a fallback for compositors that don't +// report physical dimensions (physical_width_mm == 0). +namespace { +struct WlOutputState { int32_t physWidthMm = 0; int32_t pixelWidth = 0; }; +static WlOutputState s_wlOutputState; +} + +static void onWlOutputGeometry(void*, wl_output*, int32_t, int32_t, + int32_t physical_width_mm, int32_t, + int32_t, const char*, const char*, int32_t) +{ + s_wlOutputState.physWidthMm = physical_width_mm; +} +static void onWlOutputMode(void*, wl_output*, uint32_t flags, int32_t width, int32_t, int32_t) +{ + // WL_OUTPUT_MODE_CURRENT = 0x1 + if (flags & 0x1) + s_wlOutputState.pixelWidth = width; +} +static void onWlOutputDone(void*, wl_output*) +{ + // Compute DPI-based scale once both geometry and mode have arrived. + g_Log->Info("CDisplayVulkan: wl_output done — physWidthMm=%d pixelWidth=%d", + s_wlOutputState.physWidthMm, s_wlOutputState.pixelWidth); + if (s_wlOutputState.physWidthMm > 0 && s_wlOutputState.pixelWidth > 0) + { + float dpi = static_cast(s_wlOutputState.pixelWidth) * 25.4f + / static_cast(s_wlOutputState.physWidthMm); + float scale = dpi / 96.0f; + g_Log->Info("CDisplayVulkan: wl_output DPI=%.1f -> uiScale=%.3f", dpi, scale); + // Use the highest scale across all connected outputs — avoids a lower-DPI + // secondary monitor overwriting the scale of the primary HiDPI display. + if (scale > PlatformUtils_GetUIScale()) + PlatformUtils_SetUIScale(scale); + } + else + { + g_Log->Info("CDisplayVulkan: wl_output no physical dimensions, uiScale unchanged at %.3f", + PlatformUtils_GetUIScale()); + } +} +static void onWlOutputScale(void*, wl_output*, int32_t factor) +{ + // Only used as fallback when physical dimensions are unavailable. + if (factor >= 2 && s_wlOutputState.physWidthMm == 0) + PlatformUtils_SetUIScale(static_cast(factor)); +} +static const wl_output_listener s_wlOutputListener = { + onWlOutputGeometry, onWlOutputMode, onWlOutputDone, onWlOutputScale, +}; + void CDisplayVulkan::onRegistryGlobal(void* data, wl_registry* registry, uint32_t name, const char* interface, uint32_t version) @@ -141,6 +195,13 @@ void CDisplayVulkan::onRegistryGlobal(void* data, wl_registry* registry, self->m_pDecorationManager = static_cast( wl_registry_bind(registry, name, &zxdg_decoration_manager_v1_interface, 1)); } + else if (strcmp(interface, "wl_output") == 0 && version >= 2) + { + // Bind the first output to read its integer scale factor for HiDPI. + wl_output* output = static_cast( + wl_registry_bind(registry, name, &wl_output_interface, 2)); + wl_output_add_listener(output, &s_wlOutputListener, nullptr); + } } void CDisplayVulkan::onRegistryGlobalRemove(void*, wl_registry*, uint32_t) {} @@ -497,6 +558,7 @@ void CDisplayVulkan::onKeyboardKey(void* data, wl_keyboard*, uint32_t, case XKB_KEY_n: spEvent->m_Code = CKeyEvent::KEY_N; break; case XKB_KEY_b: spEvent->m_Code = CKeyEvent::KEY_B; break; case XKB_KEY_q: spEvent->m_Code = CKeyEvent::KEY_Q; break; + case XKB_KEY_comma: spEvent->m_Code = CKeyEvent::KEY_Comma; break; case XKB_KEY_space: spEvent->m_Code = CKeyEvent::KEY_SPACE; break; case XKB_KEY_Left: spEvent->m_Code = CKeyEvent::KEY_LEFT; break; case XKB_KEY_Right: spEvent->m_Code = CKeyEvent::KEY_RIGHT; break; @@ -506,6 +568,12 @@ void CDisplayVulkan::onKeyboardKey(void* data, wl_keyboard*, uint32_t, default: spEvent->m_Code = CKeyEvent::KEY_NONE; break; } + // Feed into ImGui overlays while they're visible; consume the event if accepted. + if (FirstTimeSetupVulkan_FeedKey(key, keysym, spEvent->m_bPressed, self->m_pXkbState)) + return; + if (SettingsDialogVulkan_FeedKey(key, keysym, spEvent->m_bPressed, self->m_pXkbState)) + return; + self->m_EventQueue.push(std::static_pointer_cast(spEvent)); } @@ -538,9 +606,12 @@ void CDisplayVulkan::onPointerEnter(void* data, wl_pointer*, uint32_t serial, #endif self->updateWaylandCursor(); - auto& cb = PlatformUtils_GetMouseCallback(); - if (cb) - cb(wl_fixed_to_int(surface_x), wl_fixed_to_int(surface_y)); + FirstTimeSetupVulkan_FeedMousePos(wl_fixed_to_int(surface_x), wl_fixed_to_int(surface_y)); + SettingsDialogVulkan_FeedMousePos(wl_fixed_to_int(surface_x), wl_fixed_to_int(surface_y)); + + auto& mouseCallback = PlatformUtils_GetMouseCallback(); + if (mouseCallback) + mouseCallback(wl_fixed_to_int(surface_x), wl_fixed_to_int(surface_y)); } void CDisplayVulkan::onPointerLeave(void* data, wl_pointer*, uint32_t, wl_surface*) @@ -564,9 +635,12 @@ void CDisplayVulkan::onPointerMotion(void* data, wl_pointer*, uint32_t, #endif self->updateWaylandCursor(); - auto& cb = PlatformUtils_GetMouseCallback(); - if (cb) - cb(wl_fixed_to_int(surface_x), wl_fixed_to_int(surface_y)); + FirstTimeSetupVulkan_FeedMousePos(wl_fixed_to_int(surface_x), wl_fixed_to_int(surface_y)); + SettingsDialogVulkan_FeedMousePos(wl_fixed_to_int(surface_x), wl_fixed_to_int(surface_y)); + + auto& mouseCallback = PlatformUtils_GetMouseCallback(); + if (mouseCallback) + mouseCallback(wl_fixed_to_int(surface_x), wl_fixed_to_int(surface_y)); } void CDisplayVulkan::onPointerButton(void* data, wl_pointer*, uint32_t serial, @@ -587,6 +661,9 @@ void CDisplayVulkan::onPointerButton(void* data, wl_pointer*, uint32_t serial, serial, self->m_hoverResizeEdge); } #endif + + FirstTimeSetupVulkan_FeedMouseButton(button, state == WL_POINTER_BUTTON_STATE_PRESSED); + SettingsDialogVulkan_FeedMouseButton(button, state == WL_POINTER_BUTTON_STATE_PRESSED); } void CDisplayVulkan::onPointerAxis(void*, wl_pointer*, uint32_t, uint32_t, wl_fixed_t) {} @@ -808,6 +885,10 @@ bool CDisplayVulkan::Initialize(const uint32_t _width, const uint32_t _height, m_Width = _width; m_Height = _height; + // Seed UI scale from desktop env vars early; display-specific init below + // may override with a more precise value (wl_output scale or X11 DPI). + PlatformUtils_InitUIScale(); + const char* xssId = getenv("XSCREENSAVER_WINDOW"); #ifdef HAVE_WAYLAND @@ -846,6 +927,16 @@ bool CDisplayVulkan::Initialize(const uint32_t _width, const uint32_t _height, m_WidthFS = static_cast(DisplayWidth (m_pDisplay, screen)); m_HeightFS = static_cast(DisplayHeight(m_pDisplay, screen)); + // Derive UI scale from physical display DPI (DisplayWidthMM is the physical + // width in millimetres reported by the X server / XRandR). + int physWidthMM = DisplayWidthMM(m_pDisplay, screen); + if (physWidthMM > 0) + { + float dpi = static_cast(DisplayWidth(m_pDisplay, screen)) * 25.4f + / static_cast(physWidthMM); + PlatformUtils_SetUIScale(dpi / 96.0f); + } + m_wmDeleteWindow = XInternAtom(m_pDisplay, "WM_DELETE_WINDOW", False); m_netWmState = XInternAtom(m_pDisplay, "_NET_WM_STATE", False); m_netWmFullscreen = XInternAtom(m_pDisplay, "_NET_WM_STATE_FULLSCREEN", False); @@ -886,7 +977,7 @@ bool CDisplayVulkan::Initialize(const uint32_t _width, const uint32_t _height, XSelectInput(m_pDisplay, m_Window, StructureNotifyMask | KeyPressMask | KeyReleaseMask | - ButtonPressMask | PointerMotionMask); + ButtonPressMask | ButtonReleaseMask | PointerMotionMask); XSetWMProtocols(m_pDisplay, m_Window, &m_wmDeleteWindow, 1); setWindowDecorations(!_bFullscreen); @@ -1058,6 +1149,15 @@ void CDisplayVulkan::checkEvents() } #endif + // Show cursor while any ImGui overlay is up; hide it otherwise. + { + const bool overlayUp = FirstTimeSetupVulkan_IsWizardVisible() || + SettingsDialogVulkan_IsVisible(); + static bool s_cursorVisible = false; + if (overlayUp && !s_cursorVisible) { applyDefaultCursor(); s_cursorVisible = true; } + if (!overlayUp && s_cursorVisible) { applyInvisibleCursor(); s_cursorVisible = false; } + } + XEvent xEvent; while (XPending(m_pDisplay)) { @@ -1081,9 +1181,11 @@ void CDisplayVulkan::checkEvents() int nSyms = 0; KeySym* syms = XGetKeyboardMapping(m_pDisplay, xEvent.xkey.keycode, 1, &nSyms); + KeySym x11keysym = XK_VoidSymbol; if (syms) { - switch (syms[0]) + x11keysym = syms[0]; + switch (x11keysym) { case XK_F1: spEvent->m_Code = CKeyEvent::KEY_F1; break; case XK_F2: spEvent->m_Code = CKeyEvent::KEY_F2; break; @@ -1115,6 +1217,7 @@ void CDisplayVulkan::checkEvents() case XK_n: spEvent->m_Code = CKeyEvent::KEY_N; break; case XK_b: spEvent->m_Code = CKeyEvent::KEY_B; break; case XK_q: spEvent->m_Code = CKeyEvent::KEY_Q; break; + case XK_comma: spEvent->m_Code = CKeyEvent::KEY_Comma; break; case XK_space: spEvent->m_Code = CKeyEvent::KEY_SPACE; break; case XK_Left: spEvent->m_Code = CKeyEvent::KEY_LEFT; break; case XK_Right: spEvent->m_Code = CKeyEvent::KEY_RIGHT; break; @@ -1125,6 +1228,23 @@ void CDisplayVulkan::checkEvents() } XFree(syms); } + + // Feed into ImGui overlays on X11. XKB and X11 keysym values match for + // all keys used by the overlay input handlers. Pass nullptr for xkb_state + // (FeedKey handles this gracefully; text entry via XLookupString is a + // follow-up task). +#ifdef HAVE_WAYLAND + if (x11keysym != XK_VoidSymbol) + { + const uint32_t evdevKey = static_cast(xEvent.xkey.keycode) - 8; + if (FirstTimeSetupVulkan_FeedKey(evdevKey, + static_cast(x11keysym), spEvent->m_bPressed, nullptr)) + continue; + if (SettingsDialogVulkan_FeedKey(evdevKey, + static_cast(x11keysym), spEvent->m_bPressed, nullptr)) + continue; + } +#endif m_EventQueue.push(std::static_pointer_cast(spEvent)); } @@ -1142,8 +1262,21 @@ void CDisplayVulkan::checkEvents() if (xEvent.type == MotionNotify) { - auto& cb = PlatformUtils_GetMouseCallback(); - if (cb) cb(xEvent.xmotion.x, xEvent.xmotion.y); + FirstTimeSetupVulkan_FeedMousePos(xEvent.xmotion.x, xEvent.xmotion.y); + SettingsDialogVulkan_FeedMousePos(xEvent.xmotion.x, xEvent.xmotion.y); + auto& mouseCallback = PlatformUtils_GetMouseCallback(); + if (mouseCallback) mouseCallback(xEvent.xmotion.x, xEvent.xmotion.y); + } + + if (xEvent.type == ButtonPress || xEvent.type == ButtonRelease) + { + // Map X11 button numbers to BTN_LEFT/RIGHT/MIDDLE (linux/input-event-codes.h). + static const uint32_t kX11ToLinux[] = {0, 272, 273, 274}; + const uint32_t btn = (xEvent.xbutton.button <= 3) + ? kX11ToLinux[xEvent.xbutton.button] : 0; + const bool pressed = (xEvent.type == ButtonPress); + FirstTimeSetupVulkan_FeedMouseButton(btn, pressed); + SettingsDialogVulkan_FeedMouseButton(btn, pressed); } } } @@ -1263,6 +1396,15 @@ void CDisplayVulkan::applyInvisibleCursor() XFreePixmap(m_pDisplay, bm); } +void CDisplayVulkan::applyDefaultCursor() +{ + if (!m_pDisplay || !m_Window) return; + // XC_left_ptr (68) is the standard arrow cursor. + Cursor cur = XCreateFontCursor(m_pDisplay, 68); + XDefineCursor(m_pDisplay, m_Window, cur); + XFreeCursor(m_pDisplay, cur); +} + } // namespace DisplayOutput #endif // !WIN32 diff --git a/client_generic/DisplayOutput/Vulkan/DisplayVulkan.h b/client_generic/DisplayOutput/Vulkan/DisplayVulkan.h index 86d02b0d..be0cb0a9 100644 --- a/client_generic/DisplayOutput/Vulkan/DisplayVulkan.h +++ b/client_generic/DisplayOutput/Vulkan/DisplayVulkan.h @@ -147,6 +147,7 @@ class CDisplayVulkan : public CDisplayOutput void setWindowDecorations(bool enabled); void checkEvents(); void applyInvisibleCursor(); + void applyDefaultCursor(); public: CDisplayVulkan(); diff --git a/client_generic/DisplayOutput/Vulkan/RendererVulkan.cpp b/client_generic/DisplayOutput/Vulkan/RendererVulkan.cpp index a0c18aff..8c26e710 100644 --- a/client_generic/DisplayOutput/Vulkan/RendererVulkan.cpp +++ b/client_generic/DisplayOutput/Vulkan/RendererVulkan.cpp @@ -11,6 +11,9 @@ #include #include +#include "FirstTimeSetupVulkan.h" +#include "SettingsDialogVulkan.h" + #include #include #include @@ -28,8 +31,8 @@ namespace DisplayOutput CRendererVulkan::CRendererVulkan() { memset(m_imageAvailable, 0, sizeof(m_imageAvailable)); - memset(m_renderFinished, 0, sizeof(m_renderFinished)); memset(m_inFlightFence, 0, sizeof(m_inFlightFence)); + memset(m_timestampSlotValid, 0, sizeof(m_timestampSlotValid)); memset(m_vertexBuffer, 0, sizeof(m_vertexBuffer)); memset(m_vertexMemory, 0, sizeof(m_vertexMemory)); memset(m_mappedVertex, 0, sizeof(m_mappedVertex)); @@ -40,6 +43,17 @@ CRendererVulkan::~CRendererVulkan() if (m_device == VK_NULL_HANDLE) return; vkDeviceWaitIdle(m_device); + // CRenderer's base destructor runs after this derived destructor. Release + // its bound Vulkan objects now, while the device and command pool they use + // for teardown are still valid. + for (uint32_t i = 0; i < MAX_TEXUNIT; ++i) + { + m_aspActiveTextures[i].reset(); + m_aspSelectedTextures[i].reset(); + } + m_spActiveShader.reset(); + m_spSelectedShader.reset(); + // Shut down ImGui before any Vulkan resources are destroyed. if (m_imguiInitialized) { @@ -69,11 +83,10 @@ CRendererVulkan::~CRendererVulkan() } if (m_imageAvailable[i] != VK_NULL_HANDLE) vkDestroySemaphore(m_device, m_imageAvailable[i], nullptr); - if (m_renderFinished[i] != VK_NULL_HANDLE) - vkDestroySemaphore(m_device, m_renderFinished[i], nullptr); if (m_inFlightFence[i] != VK_NULL_HANDLE) vkDestroyFence(m_device, m_inFlightFence[i], nullptr); } + destroyPresentSemaphores(); if (m_timestampPool != VK_NULL_HANDLE) vkDestroyQueryPool(m_device, m_timestampPool, nullptr); // Command buffers freed with pool @@ -99,6 +112,12 @@ CRendererVulkan::~CRendererVulkan() m_device = VK_NULL_HANDLE; } +void CRendererVulkan::WaitForIdle() +{ + if (m_device != VK_NULL_HANDLE) + vkDeviceWaitIdle(m_device); +} + // --------------------------------------------------------------------------- // Memory type helper // --------------------------------------------------------------------------- @@ -291,12 +310,20 @@ bool CRendererVulkan::createSwapchain(VkSurfaceKHR surface, // Mailbox would spin the CPU freely between presents, burning cycles for no benefit. VkPresentModeKHR presentMode = VK_PRESENT_MODE_FIFO_KHR; - // Always use the requested dimensions clamped to surface bounds. - // Never blindly trust caps.currentExtent — on X11 it can lag behind ConfigureNotify - // by several frames, causing the swapchain to be created at the wrong size while - // Display()->Width/Height() already reflects the new window dimensions. - m_swapExtent.width = std::clamp(width, caps.minImageExtent.width, caps.maxImageExtent.width); - m_swapExtent.height = std::clamp(height, caps.minImageExtent.height, caps.maxImageExtent.height); + // Vulkan spec: when currentExtent is not {UINT32_MAX, UINT32_MAX} the swapchain + // MUST use that exact size. Some drivers (common on X11 with XFCE/Xfwm4) also set + // minImageExtent == maxImageExtent == currentExtent, so ignoring currentExtent and + // clamping by min/max yields the same forced value — but leaves m_swapExtent + // differing from m_spDisplay->Width/Height(), triggering an infinite recreation loop. + // Use currentExtent directly when valid; fall back to the requested size otherwise. + if (caps.currentExtent.width != UINT32_MAX) { + if (caps.currentExtent.width == 0 || caps.currentExtent.height == 0) + return false; // Window minimized/hidden — caller will retry next frame + m_swapExtent = caps.currentExtent; + } else { + m_swapExtent.width = std::clamp(width, caps.minImageExtent.width, caps.maxImageExtent.width); + m_swapExtent.height = std::clamp(height, caps.minImageExtent.height, caps.maxImageExtent.height); + } uint32_t imageCount = caps.minImageCount + 1; if (caps.maxImageCount > 0 && imageCount > caps.maxImageCount) @@ -755,11 +782,38 @@ bool CRendererVulkan::createSyncObjects() for (int i = 0; i < MAX_FRAMES_IN_FLIGHT; ++i) { if (vkCreateSemaphore(m_device, &si, nullptr, &m_imageAvailable[i]) != VK_SUCCESS || - vkCreateSemaphore(m_device, &si, nullptr, &m_renderFinished[i]) != VK_SUCCESS || vkCreateFence (m_device, &fi, nullptr, &m_inFlightFence[i]) != VK_SUCCESS) return false; } - return true; + return createPresentSemaphores(); +} + +bool CRendererVulkan::createPresentSemaphores() +{ + destroyPresentSemaphores(); + m_renderFinished.resize(m_swapImages.size(), VK_NULL_HANDLE); + + VkSemaphoreCreateInfo info{VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO}; + for (auto& semaphore : m_renderFinished) + { + if (vkCreateSemaphore(m_device, &info, nullptr, &semaphore) != VK_SUCCESS) + { + destroyPresentSemaphores(); + return false; + } + } + return !m_renderFinished.empty(); +} + +void CRendererVulkan::destroyPresentSemaphores() +{ + if (m_device != VK_NULL_HANDLE) + { + for (VkSemaphore semaphore : m_renderFinished) + if (semaphore != VK_NULL_HANDLE) + vkDestroySemaphore(m_device, semaphore, nullptr); + } + m_renderFinished.clear(); } // --------------------------------------------------------------------------- @@ -956,8 +1010,8 @@ bool CRendererVulkan::Initialize(spCDisplayOutput _spDisplay) VkSurfaceKHR surface = disp->GetSurface(); uint32_t w = disp->Width(); uint32_t h = disp->Height(); - m_surface = surface; - m_vulkanInstance = instance; + m_surface = surface; + m_vulkanInstance = instance; if (!pickPhysicalDevice(instance, surface)) return false; if (!createLogicalDevice()) return false; @@ -1073,6 +1127,7 @@ float CRendererVulkan::GetGPUUtilization() void CRendererVulkan::recreateSwapchain() { vkDeviceWaitIdle(m_device); + destroyPresentSemaphores(); // Destroy framebuffers for (auto fb : m_framebuffers) @@ -1096,7 +1151,26 @@ void CRendererVulkan::recreateSwapchain() uint32_t h = m_spDisplay->Height(); g_Log->Info("CRendererVulkan: recreating swapchain (%ux%u)", w, h); - createSwapchain(m_surface, w, h); + if (!createSwapchain(m_surface, w, h)) { + // Surface not ready (e.g. window minimized, zero-size extent from driver). + // Mark swapExtent as empty so BeginFrame retries next frame. + m_swapExtent = {0, 0}; + return; + } + + if (!createPresentSemaphores()) { + g_Log->Error("CRendererVulkan: failed to recreate presentation semaphores"); + m_swapExtent = {0, 0}; + return; + } + + // Sync the display's tracked size to what the driver actually created. + // On X11 with Xfwm4 the driver enforces currentExtent which may differ + // slightly from the last ConfigureNotify value. Without this sync, + // BeginFrame's mismatch check fires immediately after recreation and + // loops forever. + m_spDisplay->SyncSize(m_swapExtent.width, m_swapExtent.height); + createFramebuffers(); } @@ -1106,12 +1180,15 @@ bool CRendererVulkan::BeginFrame() { if (m_inFrame) return true; + // Skip frames while the window has no drawable area (minimized on some WMs). + if (m_spDisplay->Width() == 0 || m_spDisplay->Height() == 0) + return false; + // Proactively recreate the swapchain if the window size changed. // Display()->Width/Height() is kept up-to-date by ConfigureNotify (X11) and // xdg_toplevel configure (Wayland), so comparing against m_swapExtent detects // fullscreen transitions and manual resizes exactly one frame after the WM sends - // the resize event — no spurious recreations since m_swapExtent is set from those - // same dimensions (never from stale caps.currentExtent). + // the resize event. After recreation, SyncSize() keeps them in agreement. if (m_spDisplay->Width() != m_swapExtent.width || m_spDisplay->Height() != m_swapExtent.height) { recreateSwapchain(); @@ -1148,7 +1225,7 @@ bool CRendererVulkan::BeginFrame() // then reset + write the start timestamp for this slot. if (m_timestampPool != VK_NULL_HANDLE) { - if (m_timestampsValid) + if (m_timestampSlotValid[m_currentFrame]) { uint64_t ts[2] = {}; if (vkGetQueryPoolResults(m_device, m_timestampPool, @@ -1218,6 +1295,8 @@ bool CRendererVulkan::EndFrame(bool /*drawn*/) // Render ImGui draw data (text overlays etc.) inside the active render pass. if (m_imguiInitialized) { + FirstTimeSetupVulkan_DrawIfNeeded(); + SettingsDialogVulkan_DrawIfNeeded(); ImGui::Render(); ImDrawData* drawData = ImGui::GetDrawData(); if (drawData && drawData->TotalVtxCount > 0) @@ -1231,7 +1310,7 @@ bool CRendererVulkan::EndFrame(bool /*drawn*/) { vkCmdWriteTimestamp(cmd, VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, m_timestampPool, m_currentFrame * 2 + 1); - m_timestampsValid = true; + m_timestampSlotValid[m_currentFrame] = true; } vkEndCommandBuffer(cmd); @@ -1244,12 +1323,13 @@ bool CRendererVulkan::EndFrame(bool /*drawn*/) si.commandBufferCount = 1; si.pCommandBuffers = &cmd; si.signalSemaphoreCount = 1; - si.pSignalSemaphores = &m_renderFinished[m_currentFrame]; + VkSemaphore& renderFinished = m_renderFinished[m_currentImageIndex]; + si.pSignalSemaphores = &renderFinished; vkQueueSubmit(m_graphicsQueue, 1, &si, m_inFlightFence[m_currentFrame]); VkPresentInfoKHR pi{VK_STRUCTURE_TYPE_PRESENT_INFO_KHR}; pi.waitSemaphoreCount = 1; - pi.pWaitSemaphores = &m_renderFinished[m_currentFrame]; + pi.pWaitSemaphores = &renderFinished; pi.swapchainCount = 1; pi.pSwapchains = &m_swapchain; pi.pImageIndices = &m_currentImageIndex; diff --git a/client_generic/DisplayOutput/Vulkan/RendererVulkan.h b/client_generic/DisplayOutput/Vulkan/RendererVulkan.h index 13e024c4..9e741377 100644 --- a/client_generic/DisplayOutput/Vulkan/RendererVulkan.h +++ b/client_generic/DisplayOutput/Vulkan/RendererVulkan.h @@ -100,7 +100,9 @@ class CRendererVulkan : public CRenderer // Synchronisation VkSemaphore m_imageAvailable[MAX_FRAMES_IN_FLIGHT]{}; - VkSemaphore m_renderFinished[MAX_FRAMES_IN_FLIGHT]{}; + // Presentation wait semaphores are indexed by swapchain image. A frame + // fence does not guarantee the presentation engine has released one. + std::vector m_renderFinished; VkFence m_inFlightFence [MAX_FRAMES_IN_FLIGHT]{}; // Per-frame vertex buffers (HOST_VISIBLE for immediate writes) @@ -139,7 +141,7 @@ class CRendererVulkan : public CRenderer VkQueryPool m_timestampPool = VK_NULL_HANDLE; float m_gpuFrameTimeMs = 0.0f; float m_timestampPeriodNs = 0.0f; - bool m_timestampsValid = false; + bool m_timestampSlotValid[MAX_FRAMES_IN_FLIGHT]{}; // Initialisation helpers bool pickPhysicalDevice(VkInstance instance, VkSurfaceKHR surface); @@ -158,6 +160,8 @@ class CRendererVulkan : public CRenderer bool createCommandPool(); bool createCommandBuffers(); bool createSyncObjects(); + bool createPresentSemaphores(); + void destroyPresentSemaphores(); bool createVertexBuffers(); bool createWhiteTexture(); VkShaderModule loadShader(const std::string& path); @@ -176,6 +180,7 @@ class CRendererVulkan : public CRenderer virtual void Defaults() override; virtual bool BeginFrame(void) override; virtual bool EndFrame(bool drawn = true) override; + virtual void WaitForIdle() override; virtual void Apply() override; virtual void Reset(const uint32_t _flags) override; diff --git a/client_generic/LinuxBuild/BuildData.json.in b/client_generic/LinuxBuild/BuildData.json.in new file mode 100644 index 00000000..eeb407f2 --- /dev/null +++ b/client_generic/LinuxBuild/BuildData.json.in @@ -0,0 +1,5 @@ +{ + "VERSION": "@APP_VERSION@", + "REVISION": "@GIT_REVISION@", + "BUILD_DATE": "@BUILD_DATE@" +} diff --git a/client_generic/LinuxBuild/CMakeLists.txt b/client_generic/LinuxBuild/CMakeLists.txt index 8e1d00d9..5f27202a 100644 --- a/client_generic/LinuxBuild/CMakeLists.txt +++ b/client_generic/LinuxBuild/CMakeLists.txt @@ -174,6 +174,8 @@ set(SOURCES ${CLIENT}/Player.cpp ${CLIENT}/Hud.cpp ${CLIENT}/StringFormat.cpp + ${CLIENT}/FirstTimeSetupVulkan.cpp + ${CLIENT}/SettingsDialogVulkan.cpp # Common ${COMMON}/AlignedBuffer.cpp @@ -343,3 +345,32 @@ add_custom_command(TARGET infinidream POST_BUILD $ COMMENT "Copying runtime assets to binary directory" ) + +# --------------------------------------------------------------------------- +# Generate BuildData.json next to the binary so the version string is correct +# --------------------------------------------------------------------------- +if(NOT DEFINED APP_VERSION) + set(APP_VERSION "0.0.0") +endif() + +find_package(Git QUIET) +if(GIT_FOUND) + execute_process( + COMMAND ${GIT_EXECUTABLE} rev-parse --short HEAD + WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} + OUTPUT_VARIABLE GIT_REVISION + OUTPUT_STRIP_TRAILING_WHITESPACE + ERROR_QUIET + ) +endif() +if(NOT GIT_REVISION) + set(GIT_REVISION "unknown") +endif() + +string(TIMESTAMP BUILD_DATE "%Y-%m-%d" UTC) + +configure_file( + ${CMAKE_CURRENT_SOURCE_DIR}/BuildData.json.in + ${CMAKE_CURRENT_BINARY_DIR}/BuildData.json + @ONLY +) diff --git a/client_generic/LinuxBuild/PlatformUtils_Internal.h b/client_generic/LinuxBuild/PlatformUtils_Internal.h index a3a4819a..66580b15 100644 --- a/client_generic/LinuxBuild/PlatformUtils_Internal.h +++ b/client_generic/LinuxBuild/PlatformUtils_Internal.h @@ -13,3 +13,10 @@ bool PlatformUtils_GetCursorHidden(); // Returns the mouse-moved callback stored by SetOnMouseMovedCallback(). std::function& PlatformUtils_GetMouseCallback(); + +// UI scale factor for ImGui dialogs (1.0 = 96 DPI, 2.0 = 192 DPI, etc.). +// InitUIScale() seeds from env vars (GDK_SCALE, QT_SCALE_FACTOR); display +// init then calls SetUIScale() with a more precise value if available. +void PlatformUtils_InitUIScale(); +float PlatformUtils_GetUIScale(); +void PlatformUtils_SetUIScale(float scale); diff --git a/client_generic/LinuxBuild/PlatformUtils_Linux.cpp b/client_generic/LinuxBuild/PlatformUtils_Linux.cpp index 9fe858ae..27dfcccd 100644 --- a/client_generic/LinuxBuild/PlatformUtils_Linux.cpp +++ b/client_generic/LinuxBuild/PlatformUtils_Linux.cpp @@ -27,8 +27,44 @@ #include #include #include +#include +#include #include +// --------------------------------------------------------------------------- +// UI scale factor — set once at display init, read by ImGui dialog code. +// --------------------------------------------------------------------------- +static float g_platformUIScale = 1.0f; + +void PlatformUtils_InitUIScale() +{ + // Seed from desktop env vars — set by GNOME/KDE/etc. on HiDPI Wayland sessions. + // X11 init in DisplayVulkan will later override with the actual DPI value. + const char* gdk = std::getenv("GDK_SCALE"); + if (gdk) + { + float v = std::atof(gdk); + if (v >= 1.0f) { g_platformUIScale = v; return; } + } + const char* qt = std::getenv("QT_SCALE_FACTOR"); + if (qt) + { + float v = std::atof(qt); + if (v >= 1.0f) { g_platformUIScale = v; return; } + } +} + +float PlatformUtils_GetUIScale() +{ + return g_platformUIScale; +} + +void PlatformUtils_SetUIScale(float scale) +{ + // Clamp to a sane range; fractional values (e.g. 1.25, 1.5) are valid. + g_platformUIScale = std::max(1.0f, std::min(scale, 4.0f)); +} + // --------------------------------------------------------------------------- // Internet reachability — try a non-blocking connect to 8.8.8.8:53 // --------------------------------------------------------------------------- diff --git a/client_generic/LinuxBuild/build_appimage.py b/client_generic/LinuxBuild/build_appimage.py index f3f0d99a..dcfed5ca 100644 --- a/client_generic/LinuxBuild/build_appimage.py +++ b/client_generic/LinuxBuild/build_appimage.py @@ -498,6 +498,10 @@ def assemble_appdir() -> None: (APPDIR / "usr" / "lib").mkdir(parents=True, exist_ok=True) shutil.copy2(BUILD_DIR / "infinidream", APPDIR / "usr" / "bin" / "infinidream") + # PlatformUtils reads build metadata beside the executable. Keep the + # packaged runtime banner consistent with an unpackaged Linux build. + shutil.copy2(BUILD_DIR / "BuildData.json", + APPDIR / "usr" / "bin" / "BuildData.json") shaders_src = BUILD_DIR / "shaders" shaders_dst = APPDIR / "usr" / "bin" / "shaders" diff --git a/client_generic/Networking/EDreamClient.cpp b/client_generic/Networking/EDreamClient.cpp index e6068c4d..1a847cd8 100644 --- a/client_generic/Networking/EDreamClient.cpp +++ b/client_generic/Networking/EDreamClient.cpp @@ -75,6 +75,11 @@ void ESShowFirstTimeSetup() } } +bool ESHasFirstTimeSetupCallback() +{ + return gShowFirstTimeSetupCallback != nullptr; +} + long long EDreamClient::remainingQuota = 0; std::chrono::system_clock::time_point EDreamClient::quotaExpiresAt = std::chrono::system_clock::now(); @@ -681,9 +686,17 @@ bool EDreamClient::Authenticate() g_Log->Warning("No sealed session found"); // Try magic link login via email in settings (settings.generator.nickname). + // On Linux, skip the console path if a GUI wizard is registered — the wizard + // will handle authentication and the auth loop will retry automatically. + // On Mac/Windows the GUI wizard is always registered but those platforms use + // their own GUI auth flow; magic link is still attempted as a fallback. // ValidateCodeDetailed() calls RefreshSealedSession() internally — no // second refresh needed if this returns true. +#ifdef LINUX_GNU + if (!ESHasFirstTimeSetupCallback() && LoginWithMagicLinkCode()) +#else if (LoginWithMagicLinkCode()) +#endif { fIsLoggedIn.exchange(true); fInitialAuthComplete.store(true); @@ -732,10 +745,7 @@ bool EDreamClient::Authenticate() fAuthCV.notify_one(); if (!shownSettingsOnce) { shownSettingsOnce = true; - bool firstTimeSetupCompleted = g_Settings()->Get("settings.app.firsttimesetup", false); - if (!firstTimeSetupCompleted) { - ESShowFirstTimeSetup(); - } + ESShowFirstTimeSetup(); } return false; } @@ -1887,17 +1897,21 @@ std::future EDreamClient::EnqueuePlaylistAsync(const std::string& uuid) { return false; } + // Parse on this network worker before handing the playlist to the + // player thread. ParsePlaylist fills any missing dream metadata and + // prefetches the first streaming URL; both operations may block on the + // server and must not pause rendering during a playlist switch. + auto entries = ParsePlaylist(uuid); + if (entries.empty()) { + g_Log->Error("Failed to prepare playlist. UUID: %s", uuid.c_str()); + return false; + } + // save the current playlist id, this will get reused at next startup g_Settings()->Set("settings.content.current_playlist_uuid", uuid); - std::thread([uuid]() { - // These operations must happen on the main/UI thread - g_Log->Info("Will call set playlist"); - g_Player().SetPlaylist(std::string(uuid), false); - g_Player().SetTransitionDuration(1.0f); - g_Log->Info("Will call start transition"); - g_Player().StartTransition(); - }).detach(); + // Player and renderer state is owned by the frame-update thread. + g_Player().EnqueuePlaylistChange(uuid); return true; }); @@ -2447,10 +2461,14 @@ std::vector EDreamClient::ParsePlaylist(std::string_view uuid) { auto dream = cm.getDream(needsStreamingUuid); if (dream) { - // Grab streaming URL and save it for later use - g_Log->Info("Parse playlist blocking call for download link"); - auto path = EDreamClient::GetDreamDownloadLink(dream->uuid); - dream->setStreamingUrl(path); + // EnqueuePlaylistAsync prepares this on its network worker before + // the player parses the playlist. Do not perform the request again + // when the player-thread parse sees the already prepared dream. + if (dream->getStreamingUrl().empty()) { + g_Log->Info("Parse playlist blocking call for download link"); + auto path = EDreamClient::GetDreamDownloadLink(dream->uuid); + dream->setStreamingUrl(path); + } } else { // Metadata fetch failed or didn't include this dream; skip the // prefetch. The entry gets filtered out of the playlist anyway @@ -2539,10 +2557,7 @@ bool EDreamClient::EnqueuePlaylist(std::string_view uuid) { // save the current playlist id, this will get reused at next startup g_Settings()->Set("settings.content.current_playlist_uuid", uuid); - g_Player().SetPlaylist(std::string(uuid), false); - - g_Player().SetTransitionDuration(1.0f); - g_Player().StartTransition(); + g_Player().EnqueuePlaylistChange(std::string(uuid)); return true; } @@ -2613,7 +2628,7 @@ static void OnWebSocketMessage(sio::event& _wsEvent) } g_Log->Info("should play : %s", uuid.data()); - g_Player().PlayDreamNow(uuid.data(), frameNumber); + g_Player().EnqueuePlayDream(std::string(uuid), frameNumber); } else if (event == WsEvent::kPlayPlaylist) { @@ -2965,4 +2980,3 @@ void EDreamClient::Report(std::string uuid) { void EDreamClient::SetCPUUsage(int _cpuUsage) { fCpuUsage.exchange(_cpuUsage); } - From 041a1f342e967f13c1a15600d0bbf8df40304db9 Mon Sep 17 00:00:00 2001 From: Scott Draves Date: Fri, 31 Jul 2026 17:47:13 -0400 Subject: [PATCH 2/2] Fix x86_64 link error by avoiding boost::chrono in retry sleep The 1-second retry sleep in FindDreamsThread used boost::this_thread::sleep_for(boost::chrono::seconds(1)), which pulls in the compiled boost::chrono library (steady_clock::now()). The x86_64 slice of that library isn't linked, so universal builds failed: Undefined symbols for architecture x86_64: boost::chrono::steady_clock::now() referenced from boost::condition_variable::wait_for<...> in DreamDownloader.o Use boost::this_thread::sleep(boost::get_system_time() + boost::posix_time::seconds(1)) instead, which is header-only and needs no boost::chrono link dependency. Co-Authored-By: Claude Fable 5 --- client_generic/ContentDownloader/DreamDownloader.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client_generic/ContentDownloader/DreamDownloader.cpp b/client_generic/ContentDownloader/DreamDownloader.cpp index e711ad82..827d3bcb 100644 --- a/client_generic/ContentDownloader/DreamDownloader.cpp +++ b/client_generic/ContentDownloader/DreamDownloader.cpp @@ -219,7 +219,8 @@ void DreamDownloader::FindDreamsThread() { // of requests per second (especially after shutdown aborts I/O). if (!isRunning.load()) break; - boost::this_thread::sleep_for(boost::chrono::seconds(1)); + boost::this_thread::sleep(boost::get_system_time() + + boost::posix_time::seconds(1)); } }