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..827d3bcb 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,14 @@ 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(boost::get_system_time() + + boost::posix_time::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); } -