#include #include #include #include #ifdef __SWITCH__ #include #endif #include "sppm.hpp" #include "export.hpp" #include "audio_upload_server.hpp" #include "branding_config_server.hpp" #include "branding_bundle.hpp" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #ifdef __SWITCH__ #include #include #include #include #include #include #endif #ifdef _WIN32 #include #endif #include #include #include namespace { constexpr int W = 1280; constexpr int H = 720; const SDL_Color ORANGE{255, 117, 30, 255}; const SDL_Color UPDATE_ORANGE{255, 100, 0, 255}; const SDL_Color CREAM{255, 211, 166, 255}; const SDL_Color GREEN{86, 166, 41, 255}; const SDL_Color PALE_GREEN{169, 235, 129, 255}; const SDL_Color INK{28, 30, 35, 255}; const SDL_Color DRAW_BLACK{28, 30, 35, 255}; const SDL_Color LIGHT{226, 230, 242, 255}; const SDL_Color WHITE{255, 255, 255, 255}; const SDL_Color GRAY{140, 145, 150, 255}; constexpr std::uint32_t kScreenFadeMs = 250; constexpr float kCanvasChromeMs = 125.0f; constexpr int kCanvasChromeTravel = 150; constexpr std::array kInkPresetIndexes{{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}}; constexpr const char* kGalleryServerHost = "reximemo.net"; constexpr const char* kGalleryServerBasePath = "/nswitch"; constexpr std::uint16_t kGalleryServerPort = 443; constexpr const char* kInstalledAppVersion = "0.1.51"; std::atomic galleryRequestCounter{0}; std::string jsonStringField(const std::string& json, const std::string& key) { const std::string marker = "\"" + key + "\""; std::size_t position = json.find(marker); if (position == std::string::npos) return {}; position = json.find(':', position + marker.size()); if (position == std::string::npos) return {}; ++position; while (position < json.size() && std::isspace(static_cast(json[position]))) ++position; if (position >= json.size() || json[position++] != '"') return {}; std::string value; while (position < json.size()) { const char c = json[position++]; if (c == '"') return value; if (c == '\\') { if (position >= json.size()) return {}; const char escaped = json[position++]; switch (escaped) { case '"': case '\\': case '/': value.push_back(escaped); break; case 'n': value.push_back('\n'); break; case 'r': value.push_back('\r'); break; case 't': value.push_back('\t'); break; default: return {}; } } else value.push_back(c); } return {}; } bool versionParts(const std::string& input, std::vector& parts) { std::string version = input; if (version.rfind("Ver ", 0) == 0) version.erase(0, 4); else if (!version.empty() && (version[0] == 'v' || version[0] == 'V')) version.erase(0, 1); if (version.empty()) return false; std::size_t begin = 0; while (begin < version.size()) { const std::size_t end = version.find('.', begin); const std::size_t stop = end == std::string::npos ? version.size() : end; if (stop == begin) return false; unsigned long value = 0; for (std::size_t i = begin; i < stop; ++i) { if (version[i] < '0' || version[i] > '9') return false; const unsigned digit = static_cast(version[i] - '0'); if (value > (std::numeric_limits::max() - digit) / 10) return false; value = value * 10 + digit; } parts.push_back(value); if (end == std::string::npos) break; begin = end + 1; } return !parts.empty() && version.back() != '.'; } int compareVersions(const std::string& left, const std::string& right) { std::vector a, b; if (!versionParts(left, a) || !versionParts(right, b)) return 0; const std::size_t count = std::max(a.size(), b.size()); for (std::size_t i = 0; i < count; ++i) { const unsigned long av = i < a.size() ? a[i] : 0; const unsigned long bv = i < b.size() ? b[i] : 0; if (av != bv) return av < bv ? -1 : 1; } return 0; } bool trustedUpdatePath(const std::string& url, std::string& path) { constexpr const char* scheme = "https://"; if (url.rfind(scheme, 0) != 0) return false; const std::size_t authorityStart = std::strlen(scheme); const std::size_t slash = url.find('/', authorityStart); if (slash == std::string::npos) return false; const std::string authority = url.substr(authorityStart, slash - authorityStart); if (authority != kGalleryServerHost && authority != std::string(kGalleryServerHost) + ":443") return false; const std::string fullPath = url.substr(slash); if (fullPath.rfind(std::string(kGalleryServerBasePath) + "/", 0) != 0) return false; path = fullPath.substr(std::strlen(kGalleryServerBasePath)); return (path == "/api/download" || path.rfind("/api/download?", 0) == 0) && path.find('#') == std::string::npos; } bool validSha256Hex(const std::string& value) { return value.size() == 64 && std::all_of(value.begin(), value.end(), [](unsigned char c) { return std::isxdigit(c) != 0; }); } bool validNroFile(const std::string& path) { std::ifstream input(path, std::ios::binary); if (!input) return false; input.seekg(0, std::ios::end); const std::streamoff size = input.tellg(); if (size < 20 || size > 72LL * 1024LL * 1024LL) return false; char magic[4]{}; input.seekg(16, std::ios::beg); input.read(magic, sizeof(magic)); return input.gcount() == static_cast(sizeof(magic)) && std::memcmp(magic, "NRO0", sizeof(magic)) == 0; } std::string canonicalSdPath(std::string path) { while (!path.empty() && std::isspace(static_cast(path.front()))) path.erase(path.begin()); while (!path.empty() && std::isspace(static_cast(path.back()))) path.pop_back(); if (path.size() >= 2 && path.front() == '"' && path.back() == '"') path = path.substr(1, path.size() - 2); std::replace(path.begin(), path.end(), '\\', '/'); std::transform(path.begin(), path.end(), path.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); if (path.rfind("sdmc:", 0) == 0) { path.erase(0, 5); while (!path.empty() && path.front() == '/') path.erase(path.begin()); return "sdmc:/" + path; } while (!path.empty() && path.front() == '/') path.erase(path.begin()); if (path.rfind("switch/", 0) == 0) return "sdmc:/" + path; return path; } bool sameSdPath(const std::string& left, const std::string& right) { return canonicalSdPath(left) == canonicalSdPath(right); } int retryAfterSeconds(const std::string& headers) { std::istringstream lines(headers); std::string line; while (std::getline(lines, line)) { if (!line.empty() && line.back() == '\r') line.pop_back(); const std::size_t colon = line.find(':'); if (colon == std::string::npos) continue; std::string name = line.substr(0, colon); std::transform(name.begin(), name.end(), name.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); if (name != "retry-after") continue; std::string value = line.substr(colon + 1); while (!value.empty() && std::isspace(static_cast(value.front()))) value.erase(value.begin()); if (value.empty()) return -1; int seconds = 0; for (const char c : value) { if (c < '0' || c > '9') return -1; if (seconds > 18000) return -1; seconds = seconds * 10 + (c - '0'); } return std::clamp(seconds, 0, 1800); } return -1; } enum class Screen { Title, Library, Details, Sequence, Canvas, Viewer, Settings, Gallery, GalleryDetails, GalleryProfile, GalleryComments, CommentCanvas, AppletWarning }; enum class GalleryLoadKind { Feed, Profile }; enum class UpdateTask { None, Check, Download }; enum class Tool { Pen, Brush, Eraser }; enum class Modal { None, Folder, Audio, Branding, Export, ConfirmDelete, ConfirmGalleryDelete, GalleryPostChoice, GalleryNotice, GalleryOffline, GalleryTerms, Credits, Help, TextInput, Options, PhotoProbe, PhotoChoice, PhotoAlbum, PhotoUpload, Photo, UpdateAvailable }; enum class ServerStartTask { None, Audio, Photo, Branding }; enum class Field { None, Title, Description, FolderName, CommentText, GalleryBio }; struct ScreenState { enum class Phase { Idle, ToWhite, FromWhite }; Screen active = Screen::Title; Screen target = Screen::Title; Phase phase = Phase::Idle; std::uint32_t phaseStart = 0; operator Screen() const { return active; } ScreenState& operator=(Screen next) { if (phase == Phase::Idle && active == next) return *this; if (phase != Phase::Idle && target == next) return *this; target = next; phase = Phase::ToWhite; phaseStart = SDL_GetTicks(); return *this; } void beginTitleIntro() { active = target = Screen::Title; phase = Phase::FromWhite; phaseStart = SDL_GetTicks(); } bool transitioning() const { return phase != Phase::Idle; } void advance(std::uint32_t now) { if (!transitioning() || now - phaseStart < kScreenFadeMs) return; if (phase == Phase::ToWhite) { active = target; phase = Phase::FromWhite; phaseStart = now; } else { phase = Phase::Idle; } } Uint8 whiteAlpha(std::uint32_t now) const { if (phase == Phase::Idle) return 0; const float progress = std::clamp((now - phaseStart) / static_cast(kScreenFadeMs), 0.0f, 1.0f); return static_cast(std::lround((phase == Phase::ToWhite ? progress : 1.0f - progress) * 255.0f)); } }; struct Item { std::string title; std::string path; std::string artwork; std::string author; bool sample = false; std::string description; std::string originalAuthor; std::string authorFsid; std::string originalAuthorFsid; std::string currentId; std::string rootId; std::uint64_t createdUnixSeconds = 0; std::uint64_t modifiedUnixSeconds = 0; bool allowSpinoffs = true; bool locked = false; std::string galleryId; std::string fsid; std::string ownerIdentity; int coins = 0; int views = 0; std::vector previewBytes; }; struct GalleryComment { std::string author; std::string text; std::string created; std::vector drawing; }; struct GalleryProfileData { std::string identity; std::string username; std::string bio; int switchnotes = 0; int views = 0; int coins = 0; int followers = 0; int following = 0; bool followed = false; bool blocked = false; std::vector avatar; std::vector uploads; }; struct UndoState { std::size_t frame = 0; int layer = 0; std::vector pixels; }; struct FrameTexture { SDL_Texture* texture = nullptr; std::uint64_t revision = 0; }; const std::array kSpeedFps{{1, 1, 2, 4, 6, 12, 20, 30}}; const std::array kLegacySpeedFps{{3, 5, 8, 12, 15, 18, 24, 30}}; const std::array kSpeedRates{{0.4985, 1.0, 2.0, 4.0, 5.9972, 11.986, 19.9459, 29.9275}}; double selectedFrameRate(int speed, int legacyFps) { return speed >= 1 && speed <= 8 ? kSpeedRates[static_cast(speed - 1)] : std::max(1, legacyFps); } int speedFromFps(int fps) { int closest = 0; for (int i = 1; i < static_cast(kLegacySpeedFps.size()); ++i) { if (std::abs(kLegacySpeedFps[i] - fps) < std::abs(kLegacySpeedFps[closest] - fps)) closest = i; } return closest + 1; } int documentSpeed(const sppm::Metadata& metadata) { return metadata.speed >= 1 && metadata.speed <= 8 ? metadata.speed : speedFromFps(metadata.fps); } std::string join(const std::string& a, const std::string& b) { return a + "/" + b; } std::string percentEncode(const std::string& input) { constexpr char hex[] = "0123456789ABCDEF"; std::string out; out.reserve(input.size() * 3); for (unsigned char ch : input) { if ((ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '-' || ch == '_' || ch == '.' || ch == '~') { out.push_back(static_cast(ch)); } else { out.push_back('%'); out.push_back(hex[ch >> 4]); out.push_back(hex[ch & 15]); } } return out; } int hexDigit(char ch) { if (ch >= '0' && ch <= '9') return ch - '0'; if (ch >= 'A' && ch <= 'F') return ch - 'A' + 10; if (ch >= 'a' && ch <= 'f') return ch - 'a' + 10; return -1; } std::string hexEncode(const std::uint8_t* bytes, std::size_t size) { constexpr char hex[] = "0123456789ABCDEF"; std::string encoded; encoded.reserve(size * 2); for (std::size_t i = 0; i < size; ++i) { encoded.push_back(hex[bytes[i] >> 4]); encoded.push_back(hex[bytes[i] & 15]); } return encoded; } bool hexDecode(const std::string& encoded, std::uint8_t* bytes, std::size_t size) { if (encoded.size() != size * 2 || (size && !bytes)) return false; for (std::size_t i = 0; i < size; ++i) { const int high = hexDigit(encoded[i * 2]); const int low = hexDigit(encoded[i * 2 + 1]); if (high < 0 || low < 0) return false; bytes[i] = static_cast((high << 4) | low); } return true; } std::string percentDecode(const std::string& input) { std::string out; out.reserve(input.size()); for (std::size_t i = 0; i < input.size(); ++i) { if (input[i] == '%' && i + 2 < input.size()) { const int hi = hexDigit(input[i + 1]), lo = hexDigit(input[i + 2]); if (hi >= 0 && lo >= 0) { out.push_back(static_cast((hi << 4) | lo)); i += 2; continue; } } if (input[i] == '+') out.push_back(' '); else out.push_back(input[i]); } return out; } std::vector splitTabs(const std::string& line) { std::vector fields; std::size_t start = 0; while (true) { const std::size_t end = line.find('\t', start); fields.emplace_back(line.substr(start, end == std::string::npos ? std::string::npos : end - start)); if (end == std::string::npos) break; start = end + 1; } return fields; } std::string base64Encode(const std::vector& input) { constexpr char alphabet[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; std::string out; out.reserve(((input.size() + 2) / 3) * 4); for (std::size_t i = 0; i < input.size(); i += 3) { const std::uint32_t a = input[i]; const std::uint32_t b = i + 1 < input.size() ? input[i + 1] : 0; const std::uint32_t c = i + 2 < input.size() ? input[i + 2] : 0; const std::uint32_t n = (a << 16) | (b << 8) | c; out.push_back(alphabet[(n >> 18) & 63]); out.push_back(alphabet[(n >> 12) & 63]); out.push_back(i + 1 < input.size() ? alphabet[(n >> 6) & 63] : '='); out.push_back(i + 2 < input.size() ? alphabet[n & 63] : '='); } return out; } std::vector base64Decode(const std::string& input) { std::array values{}; values.fill(-1); const std::string alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; for (int i = 0; i < static_cast(alphabet.size()); ++i) values[static_cast(alphabet[static_cast(i)])] = i; std::vector out; out.reserve((input.size() / 4) * 3); std::uint32_t accumulator = 0; int bits = 0; for (unsigned char ch : input) { if (ch == '=') break; const int value = values[ch]; if (value < 0) continue; accumulator = (accumulator << 6) | static_cast(value); bits += 6; if (bits >= 8) { bits -= 8; out.push_back(static_cast((accumulator >> bits) & 0xFF)); } } return out; } int blackInkIndex(const sppm::Palette& palette) { int bestIndex = 2; int bestDistance = std::numeric_limits::max(); for (int i = 1; i < static_cast(palette.size()); ++i) { const auto& color = palette[static_cast(i)]; if (!color.a) continue; const int distance = static_cast(color.r) * color.r + static_cast(color.g) * color.g + static_cast(color.b) * color.b; if (distance < bestDistance) { bestDistance = distance; bestIndex = i; } } return bestIndex; } void logStartup(const std::string& message, bool reset = false) { #ifdef __SWITCH__ const char* path = "sdmc:/switch/Switchnote/startup.log"; #else const char* path = "data/startup.log"; #endif std::ofstream log(path, std::ios::binary | (reset ? std::ios::trunc : std::ios::app)); if (log) log << message << '\n'; std::fprintf(stderr, "%s\n", message.c_str()); } bool endsWith(const std::string& text, const std::string& suffix) { return text.size() >= suffix.size() && text.compare(text.size() - suffix.size(), suffix.size(), suffix) == 0; } std::string utf8Prefix(const std::string& value, std::size_t maxCharacters) { std::size_t offset = 0; std::size_t characters = 0; while (offset < value.size() && characters < maxCharacters) { const auto lead = static_cast(value[offset]); const std::size_t bytes = lead < 0x80 ? 1 : (lead & 0xe0) == 0xc0 ? 2 : (lead & 0xf0) == 0xe0 ? 3 : (lead & 0xf8) == 0xf0 ? 4 : 1; offset += std::min(bytes, value.size() - offset); ++characters; } return value.substr(0, offset); } void ensureDir(const std::string& path) { std::string current; current.reserve(path.size()); auto make = [](const std::string& directory) { #ifdef _WIN32 _mkdir(directory.c_str()); #else mkdir(directory.c_str(), 0777); #endif }; for (char ch : path) { current.push_back(ch); if (ch == '/' || ch == '\\') make(current); } make(current); } bool copyMissingTree(const std::string& source, const std::string& destination, bool renameLegacyNotes = false) { struct stat sourceInfo{}; if (stat(source.c_str(), &sourceInfo) != 0) return true; if (S_ISDIR(sourceInfo.st_mode)) { ensureDir(destination); DIR* directory = opendir(source.c_str()); if (!directory) return false; bool ok = true; while (dirent* entry = readdir(directory)) { const std::string name = entry->d_name; if (name == "." || name == "..") continue; const std::string mappedName = renameLegacyNotes && name == "Flipnotes" ? "Switchnotes" : name; if (!copyMissingTree(join(source, name), join(destination, mappedName), false)) ok = false; } closedir(directory); return ok; } if (!S_ISREG(sourceInfo.st_mode)) return true; struct stat destinationInfo{}; if (stat(destination.c_str(), &destinationInfo) == 0) return true; std::ifstream input(source, std::ios::binary); if (!input) return false; const std::size_t separator = destination.find_last_of("/\\"); if (separator != std::string::npos) ensureDir(destination.substr(0, separator)); const std::string temporary = destination + ".migrating"; std::remove(temporary.c_str()); std::ofstream output(temporary, std::ios::binary | std::ios::trunc); if (!output) return false; std::array buffer{}; while (input) { input.read(buffer.data(), static_cast(buffer.size())); const std::streamsize count = input.gcount(); if (count > 0) output.write(buffer.data(), count); } output.flush(); output.close(); if (!input.eof() || !output) { std::remove(temporary.c_str()); return false; } if (std::rename(temporary.c_str(), destination.c_str()) != 0) { std::remove(temporary.c_str()); return false; } return true; } bool migrateLegacyStorage(const std::string& destination) { #ifdef __SWITCH__ const std::string marker = join(destination, "storage-migrated-v1"); struct stat markerInfo{}; if (stat(marker.c_str(), &markerInfo) == 0) return true; const std::string legacy = "sdmc:/switch/FlipnoteNX"; struct stat legacyInfo{}; if (stat(legacy.c_str(), &legacyInfo) != 0) return true; DIR* directory = opendir(legacy.c_str()); if (!directory) return false; bool ok = true; while (dirent* entry = readdir(directory)) { const std::string name = entry->d_name; if (name == "." || name == "..") continue; if (endsWith(name, ".nro")) continue; const bool notes = name == "flipnotes"; if (!copyMissingTree(join(legacy, name), join(destination, notes ? "switchnotes" : name), notes)) ok = false; } closedir(directory); if (!ok) return false; std::ofstream complete(marker, std::ios::binary | std::ios::trunc); if (!complete) return false; complete << "1\n"; complete.flush(); return static_cast(complete); #else (void)destination; return true; #endif } std::uint64_t nowSeconds() { return static_cast(std::time(nullptr)); } std::string dateText(std::uint64_t seconds) { if (!seconds) return "Unknown"; std::time_t value = static_cast(seconds); std::tm* tm = std::localtime(&value); if (!tm) return "Unknown"; char buffer[64]{}; std::strftime(buffer, sizeof(buffer), "%B %d %Y %H:%M:%S", tm); return buffer; } std::string defaultSwitchnoteTitle(const std::string& nickname, const std::string& noteName) { return noteName + " by " + (nickname.empty() ? noteName + " User" : nickname); } bool randomBytes(void* out, std::size_t size) { #ifdef __SWITCH__ return R_SUCCEEDED(csrngGetRandomBytes(out, size)); #else try { std::random_device rd; auto* bytes = static_cast(out); for (std::size_t i = 0; i < size; ++i) bytes[i] = static_cast(rd()); return true; } catch (...) { return false; } #endif } void appendIdentityMaterial(std::vector& out, std::uint8_t field, const std::uint8_t* bytes, std::size_t length) { out.push_back(field); out.push_back(static_cast(length)); out.insert(out.end(), bytes, bytes + length); } bool getDeviceMaterial(std::vector& material) { constexpr char domain[] = "FlipnoteStudio-console-binding-v2"; material.assign(domain, domain + sizeof(domain)); #ifdef __SWITCH__ bool found = false; Uuid pseudoId{}; if (R_SUCCEEDED(appletGetPseudoDeviceId(&pseudoId))) { appendIdentityMaterial(material, 1, pseudoId.uuid, sizeof(pseudoId.uuid)); found = true; } if (R_SUCCEEDED(setcalInitialize())) { SetCalMacAddress mac{}; if (R_SUCCEEDED(setcalGetWirelessLanMacAddress(&mac))) { appendIdentityMaterial(material, 2, mac.addr, sizeof(mac.addr)); found = true; } u64 calibratedDeviceId = 0; if (R_SUCCEEDED(setcalGetDeviceId(&calibratedDeviceId)) && calibratedDeviceId != 0) { const auto* bytes = reinterpret_cast(&calibratedDeviceId); appendIdentityMaterial(material, 3, bytes, sizeof(calibratedDeviceId)); found = true; } setcalExit(); } if (R_SUCCEEDED(splInitialize())) { u64 securityDeviceId = 0; if (R_SUCCEEDED(splGetConfig(SplConfigItem_DeviceId, &securityDeviceId)) && securityDeviceId != 0) { const auto* bytes = reinterpret_cast(&securityDeviceId); appendIdentityMaterial(material, 5, bytes, sizeof(securityDeviceId)); found = true; } splExit(); } if (!found) { material.clear(); return false; } return true; #else const char* machine = std::getenv("COMPUTERNAME"); if (!machine || !*machine) machine = std::getenv("HOSTNAME"); if (machine && *machine) { appendIdentityMaterial(material, 4, reinterpret_cast(machine), std::strlen(machine)); } else { const std::string fallback = "local-development-profile"; appendIdentityMaterial(material, 4, reinterpret_cast(fallback.data()), fallback.size()); } return true; #endif } bool readBinaryFile(const std::string& path, std::vector& bytes) { std::ifstream input(path, std::ios::binary | std::ios::ate); if (!input) return false; const std::streamoff size = input.tellg(); if (size < 0 || size > 4096) return false; bytes.resize(static_cast(size)); input.seekg(0, std::ios::beg); if (!bytes.empty()) input.read(reinterpret_cast(bytes.data()), size); return input.good() || (input.eof() && input.gcount() == size); } bool loadEncryptedIdentity(const std::string& path, const std::vector& deviceMaterial, sppm::SigningIdentity& identity) { constexpr std::size_t fileBytes = 4 + 16 + 24 + 32 + 16 + 128; std::vector bytes; if (!readBinaryFile(path, bytes) || bytes.size() != fileBytes || std::memcmp(bytes.data(), "FNI2", 4) != 0) return false; std::size_t at = 4; std::array salt{}; std::array nonce{}; std::array sealed{}; std::copy_n(bytes.begin() + static_cast(at), salt.size(), salt.begin()); at += salt.size(); std::copy_n(bytes.begin() + static_cast(at), nonce.size(), nonce.begin()); at += nonce.size(); std::copy_n(bytes.begin() + static_cast(at), identity.publicIdentity.key.size(), identity.publicIdentity.key.begin()); at += identity.publicIdentity.key.size(); std::copy_n(bytes.begin() + static_cast(at), identity.publicIdentity.deviceTag.size(), identity.publicIdentity.deviceTag.begin()); at += identity.publicIdentity.deviceTag.size(); std::copy_n(bytes.begin() + static_cast(at), sealed.size(), sealed.begin()); std::vector tagMaterial = deviceMaterial; tagMaterial.insert(tagMaterial.end(), salt.begin(), salt.end()); if (sppm::deriveDeviceTag(tagMaterial) != identity.publicIdentity.deviceTag) return false; const auto wrapKey = sppm::deriveDeviceWrapKey(deviceMaterial, salt); std::string error; if (!sppm::unsealSigningSecret(sealed, wrapKey, nonce, identity, error) || !std::equal(identity.publicIdentity.key.begin(), identity.publicIdentity.key.end(), identity.secretKey.begin() + 32)) return false; return sppm::hasPublicIdentity(identity.publicIdentity); } bool createEncryptedIdentity(const std::string& dataRoot, const std::vector& deviceMaterial, const std::array& legacyKey, sppm::SigningIdentity& identity, std::string& pathOut) { std::array salt{}; std::array nonce{}; if (!randomBytes(salt.data(), salt.size()) || !randomBytes(nonce.data(), nonce.size())) return false; std::vector tagMaterial = deviceMaterial; tagMaterial.insert(tagMaterial.end(), salt.begin(), salt.end()); std::string error; if (!sppm::createSigningIdentity(sppm::deriveDeviceTag(tagMaterial), identity, error)) return false; identity.legacyHmacKey = legacyKey; const auto wrapKey = sppm::deriveDeviceWrapKey(deviceMaterial, salt); std::array sealed{}; if (!sppm::sealSigningSecret(identity, wrapKey, nonce, sealed, error)) return false; constexpr char hex[] = "0123456789ABCDEF"; std::string saltName; saltName.reserve(32); for (std::uint8_t byte : salt) { saltName.push_back(hex[byte >> 4]); saltName.push_back(hex[byte & 15]); } pathOut = join(dataRoot, "identity-v2-" + saltName + ".bin"); std::vector bytes{'F', 'N', 'I', '2'}; bytes.insert(bytes.end(), salt.begin(), salt.end()); bytes.insert(bytes.end(), nonce.begin(), nonce.end()); bytes.insert(bytes.end(), identity.publicIdentity.key.begin(), identity.publicIdentity.key.end()); bytes.insert(bytes.end(), identity.publicIdentity.deviceTag.begin(), identity.publicIdentity.deviceTag.end()); bytes.insert(bytes.end(), sealed.begin(), sealed.end()); std::ofstream output(pathOut, std::ios::binary | std::ios::out); if (!output) return false; output.write(reinterpret_cast(bytes.data()), static_cast(bytes.size())); return static_cast(output); } struct App { SDL_Window* window = nullptr; SDL_Renderer* renderer = nullptr; SDL_GameController* controller = nullptr; bool romfsReady = false; bool csrngReady = false; bool accountReady = false; bool gallerySocketsReady = false; bool gallerySslReady = false; bool nifmReady = false; bool sdlReady = false; bool imageReady = false; bool ttfReady = false; bool audioReady = false; bool appletMode = false; #ifdef __SWITCH__ bool irsReady = false; bool irCameraRunning = false; IrsIrCameraHandle irCamera{}; Thread photoProbeThread{}; bool photoProbeThreadCreated = false; bool photoProbeRunning = false; std::atomic photoProbeFinished{false}; IrsImageTransferProcessorState irImageState{}; std::vector irImage = std::vector(320 * 240, 0); std::vector irPhoto = std::vector(320 * 240, 0); std::vector irPreviewRgba = std::vector(320 * 240 * 4, 255); SDL_Texture* irPreviewTexture = nullptr; bool irImageReady = false; bool irPhotoTaken = false; bool irRotatePhoto = false; #endif AudioUploadServer audioUploadServer; BrandingConfigServer brandingConfigServer; std::string audioUploadUrl; std::string audioUploadCode; std::string audioUploadStatus; bool photoTransferActive = false; bool photoImporting = false; bool photoProbeHasCamera = false; std::vector photoImportRgba; int photoImportRow = 0; int photoImportLayer = 0; int photoAlbumPage = 0; bool galleryTermsAccepted = false; #ifdef __SWITCH__ std::vector photoAlbumEntries; std::array photoAlbumThumbs{}; std::array photoAlbumThumbIndexes{}; #endif std::string brandingUrl; std::string brandingCode; std::string brandingStatus; std::string appDisplayName = "Switchnote Studio"; std::string noteSingular = "Switchnote"; std::string notePlural = "Switchnotes"; std::string brandingLogoPath; SDL_Texture* brandingLogoTexture = nullptr; bool renderingModal = false; int loadingDepth = 0; std::uint32_t loadingStartedAt = 0; std::uint32_t loadingFinishedAt = 0; std::uint32_t completedLoadingDuration = 0; bool loadingCompletionVisible = false; SDL_Thread* updateThread = nullptr; std::atomic updateWorkerDone{false}; bool updateInProgress = false; bool updateManualCheck = false; bool updateOfferPending = false; bool updateAvailable = false; bool updateNoChange = false; std::uint32_t updateStartedAt = 0; UpdateTask updateTask = UpdateTask::None; std::string updateServerVersion; std::string updateDownloadUrl; std::string updateSha256; std::string updateError; std::string updateDownloadedPath; int updateHttpStatus = 0; std::string launchNroPath; std::string updateInstallSourcePath; bool updateInstallPending = false; std::uint32_t updateInstallReadyAt = 0; bool photoModalTransitionDim = false; std::uint32_t photoModalTransitionDimAt = 0; std::array loadingFrogFrames{}; bool running = true; std::string assetsRoot; std::string dataRoot; std::string notesRoot; std::string exportRoot; std::string importRoot; std::string folder = "Switchnotes"; std::string userName = "Switchnote User"; std::string myFsid; std::vector profileImageBytes; SDL_Texture* profileTexture = nullptr; SDL_Texture* profileCornerTexture = nullptr; SDL_Texture* profileDetailTexture = nullptr; sppm::SigningIdentity signingIdentity{}; std::map images; std::map whiteImages; std::map, TTF_Font*> fonts; std::unordered_map textCache; std::unordered_map hoverMotion; std::map cues; std::array trackChunks{}; Mix_Chunk* bgmSeekChunk = nullptr; std::array, 4> trackAudioBuffers{}; std::map frameTextures; std::uint64_t docRevision = 1; ScreenState screen; Modal modal = Modal::None; Field editingField = Field::None; std::string inputText; std::string toast; std::uint32_t toastUntil = 0; std::vector library; std::vector gallery; std::vector galleryComments; GalleryProfileData galleryProfile; SDL_Texture* galleryProfileAvatarTexture = nullptr; bool galleryProfileIsOwn = false; Screen galleryProfileReturnScreen = Screen::Gallery; int galleryProfilePage = 0; bool galleryProfileActive = false; bool galleryProfileDetailMode = false; std::vector galleryBeforeProfile; std::size_t gallerySelectedBeforeProfile = 0; int galleryPageBeforeProfile = 0; int galleryTotalBeforeProfile = 0; std::size_t selected = 0; std::size_t gallerySelected = 0; int page = 0; int galleryPage = 0; int galleryTotal = 0; bool galleryPopular = false; bool galleryCommentsLoaded = false; int galleryCommentScroll = 0; bool galleryCommentTouchScroll = false; int galleryCommentTouchStartY = 0; int galleryCommentTouchStartOffset = 0; std::uint32_t lastGalleryCommentScrollTick = 0; std::string galleryStatus; std::size_t pendingGalleryPublishIndex = 0; bool galleryLoadRunning = false; std::atomic galleryLoadFinished{false}; std::string galleryLoadPath; GalleryLoadKind galleryLoadKind = GalleryLoadKind::Feed; std::vector galleryLoadResponse; int galleryLoadHttpStatus = 0; bool galleryLoadSucceeded = false; std::string galleryLoadError; bool serverStartRunning = false; std::atomic serverStartFinished{false}; bool serverStartSucceeded = false; ServerStartTask serverStartTask = ServerStartTask::None; #ifdef __SWITCH__ Thread galleryLoadThread{}; bool galleryLoadThreadCreated = false; Thread serverStartThread{}; bool serverStartThreadCreated = false; #endif std::string commentDraft; std::unordered_map galleryCommentTextures; sppm::Document doc; bool docLoaded = false; bool galleryViewing = false; bool dirty = false; std::uint32_t dirtyTick = 0; std::string docPath; std::size_t frame = 0; int layer = 0; int color = 2; int defaultFps = 12; int defaultSpeed = 6; int modalSelection = 0; Screen settingsReturnScreen = Screen::Library; Modal lastRenderedModal = Modal::None; Modal modalVisual = Modal::None; bool modalClosing = false; std::uint32_t modalTransitionStart = 0; std::uint32_t lastScreenCueStart = 0; int brush = 4; int pattern = 0; int eraserSlot = 1; int penType = 0; std::uint16_t penRngState = 0x1F01; int squiggleAnchorX = -1; int squiggleAnchorY = -1; int ringPreviousX = -1; int ringPreviousY = -1; bool square = false; bool advancedBrush = false; Tool activeTool = Tool::Pen; bool canvasYChordUsed = false; bool penStabilisationEnabled = false; bool penStabilisationAfter = false; int penStabilisationStrength = 50; bool stabilisedAfterReplay = false; bool realtimeStrokeInitialized = false; float realtimeStrokeX = 0.0f; float realtimeStrokeY = 0.0f; std::vector> afterStrokePoints; bool onion = true; bool canvasGrid = false; bool copyToNewPage = false; bool expandMetadata = false; bool paintMenu = false; bool colorMenu = false; bool toolMenu = false; bool canvasMenuOpen = false; float canvasMenuProgress = 0.0f; std::uint32_t lastCanvasMenuTick = 0; bool playing = false; float zoom = 1.0f; int panX = 0; int panY = 0; std::uint32_t lastPlaybackTick = 0; double playbackTickRemainder = 0.0; sppm::Frame frameClipboard; bool clipboardReady = false; std::vector undoStack; std::vector redoStack; int audioTrack = 0; int audioEraseArmedTrack = -1; std::vector importFiles; std::size_t importSelection = 0; std::vector commentPixels = std::vector(sppm::kPixelsPerLayer, 0); bool commentDirty = true; SDL_Texture* commentTexture = nullptr; int pointerX = 0; int pointerY = 0; bool pointerDown = false; bool pointerPressed = false; bool pointerReleased = false; bool clickConsumed = false; bool touchHoverExpiryPending = false; // A canvas press that began on visible chrome remains a chrome gesture for // its entire lifetime. Once a drawing gesture starts, the chrome can move // away without creating invisible "no draw" rectangles underneath it. bool canvasControlGesture = false; bool drawing = false; bool hasLastStroke = false; int lastStrokeX = 0; int lastStrokeY = 0; bool canvasTapPending = false; bool canvasFillCommitted = false; int canvasPressScreenX = 0; int canvasPressScreenY = 0; int canvasPressPixelX = 0; int canvasPressPixelY = 0; std::uint32_t canvasPressStartedAt = 0; bool fillWaveActive = false; int fillWaveOriginX = 0; int fillWaveOriginY = 0; float fillWaveRadius = 0.0f; std::uint32_t fillWaveTick = 0; std::size_t fillWaveFrame = 0; int fillWaveLayer = -1; std::uint8_t fillWaveColor = 0; std::vector> fillWavePixels; std::size_t fillWaveCursor = 0; int wheel = 0; std::uint32_t buttonsDown = 0; std::uint32_t buttonsUp = 0; std::uint32_t buttonsHeld = 0; std::uint32_t rightDpadNextRepeatAt = 0; bool rightDpadRepeating = false; float pulse = 0; float canvasChromeVisible = 1.0f; std::uint32_t lastChromeTick = 0; float leftStickX = 0.0f; float leftStickY = 0.0f; std::uint32_t lastPanTick = 0; bool init(int argc, char** argv); void shutdown(); void run(); void poll(); void handleKeys(); void update(); void startUpdateCheck(bool manual); void startUpdateDownload(); bool requestUpdateHandoff(); void completeUpdateInstall(); void runUpdateTask(); void consumeUpdateTask(); static int updateThreadEntry(void* context); void render(); void renderTitle(); void renderAppletWarning(); void renderLibrary(); void renderSettings(); void renderDetails(bool galleryMode); void renderSequence(); void renderCanvas(); void renderViewer(); void renderGallery(); void renderGalleryProfile(); void renderComments(); void renderCommentCanvas(); void renderModal(); void drawPlayTriangle(int x, int y, int size, SDL_Color color = ORANGE); void drawLockIcon(int x, int y); void renderToast(); void renderLoadingOverlay(); void beginLoading(); void endLoading(); void beginFillWave(int x, int y); void updateFillWave(std::uint32_t now); void background(); SDL_Texture* image(const std::string& relative); SDL_Texture* whiteImage(const std::string& relative); SDL_Texture* loadBrandingLogo(const std::string& path); void drawImage(const std::string& relative, int x, int y, int w, int h, bool nearest = false); void drawTexture(SDL_Texture* texture, int x, int y, int w, int h); TTF_Font* font(int px, bool bold); std::string brandString(const std::string& label) const; void rawText(const std::string& label, int x, int y, int px, SDL_Color color, bool bold); void text(const std::string& label, int x, int y, int px, SDL_Color color, bool bold = false); void centered(const std::string& label, int x, int y, int w, int h, int px, SDL_Color color, bool bold = false); void fill(int x, int y, int w, int h, SDL_Color c); void rounded(int x, int y, int w, int h, int radius, SDL_Color c); void roundedOutline(int x, int y, int w, int h, int radius, int thickness, SDL_Color c); bool hit(int x, int y, int w, int h); int hoverLift(int x, int y, bool hovered); bool pill(const std::string& label, int x, int y, int w, int h, bool green = false, int px = 22, bool enabled = true, const std::string& cue = "button_on"); bool actionPill(const std::string& icon, const std::string& label, int x, int y, int w, int h, bool green = false, bool enabled = true, const std::string& cue = "button_on"); bool dangerPill(const std::string& label, int x, int y, int w, int h, int px = 18, bool enabled = true); bool dangerActionPill(const std::string& icon, const std::string& label, int x, int y, int w, int h, int px = 19, bool enabled = true); bool imagePill(const std::string& icon, int x, int y, int w, int h, bool green = false, bool enabled = true, const std::string& cue = "button_on"); bool imageButton(const std::string& path, int x, int y, int w, int h, const std::string& cue = "button_on"); void showToast(const std::string& message); void playCue(const std::string& name); void playEvent(const std::string& event, const std::string& defaultCue); void loadPreferences(); void savePreferences(); bool loadIdentity(); std::string newId(const char* domain); void scanLibrary(); void scanImports(); void enterGallery(); void showGalleryNotice(); void confirmGalleryLeave(); void refreshGallery(); void loadGalleryProfile(const std::string& identity, Screen returnScreen, int pageIndex = 0); void leaveGalleryProfile(); void finishGalleryRefresh(const std::vector& response, int status, const std::string& error); void finishGalleryProfile(const std::vector& response, int status, const std::string& error); void saveGalleryProfile(bool quiet = false); void updateGalleryRelationship(const char* action); void consumeGalleryLoad(); bool internetConnected(); #ifdef __SWITCH__ static void galleryLoadThreadEntry(void* argument); void runGalleryLoadThread(); #endif void refreshGalleryComments(); void openGalleryComments(); void openItem(std::size_t index, bool edit); void openGalleryItem(std::size_t index); void saveGalleryItem(std::size_t index); void publishGalleryItem(std::size_t index); void performGalleryUpload(std::size_t index, bool locked); void addGalleryCoin(); void deleteGalleryItem(); void postGalleryComment(); SDL_Texture* galleryPreviewTexture(const Item& item); SDL_Texture* galleryCommentTexture(const GalleryComment& comment, std::size_t index); bool galleryRequest(const std::string& method, const std::string& path, const std::vector& requestBody, const std::string& contentType, std::vector& responseBody, int& status, std::string& error, std::string* responseHeaders = nullptr); bool loadSample(const Item& item, sppm::Document& out); bool startAudioUploadServer(bool photoTransfer = false); void requestAudioUploadServer(bool photoTransfer = false); void stopAudioUploadServer(); void closeAudioMenu(); void consumeAudioUpload(); bool startBrandingConfigServer(); void requestServerStart(ServerStartTask task); void requestBrandingConfigServer(); void consumeServerStart(); void consumePhotoProbe(); #ifdef __SWITCH__ static void serverStartThreadEntry(void* argument); void runServerStartThread(); static void photoProbeThreadEntry(void* argument); void runPhotoProbeThread(); #endif void stopBrandingConfigServer(); void closeBrandingMenu(); void consumeBrandingConfig(); void importBrandingConfig(); void exportBrandingConfig(); void newDocument(); bool saveDocument(bool force = false); void closeDocument(); void deleteDocument(); void forkDocument(); void markDirty(); void invalidateFrames(); SDL_Texture* frameTexture(std::size_t index); SDL_Texture* thumbTexture(const Item& item); void drawCurrentFrame(int x, int y, int w, int h, bool withOnion = false); void drawFrameImage(std::size_t index, int x, int y, int w, int h); void drawDocumentControls(bool galleryColor = false); void playControl(int x, int y, int w, int h); void setFrame(std::size_t index, bool playNavigationCue = true, const char* event = nullptr); void startPlayback(); void stopPlayback(bool playStopCue = true); void updatePlayback(std::uint32_t now); void playFrameSound(); void startBgmAtCurrentFrame(); void rebuildAudio(); void insertFrame(bool paste); void eraseFrame(); void copyFrame(); void undo(); void redo(); void pushUndo(); bool paintPixel(int sx, int sy, std::vector& pixels); void strokeAt(int sx, int sy, bool comment = false); void finishAfterStroke(); void beginText(Field field, const std::string& current); void applyText(); void importAudio(); bool importAudioFile(const std::string& path, int track, const std::string& displayName); void exportDocument(int destination); void openPhotoCapture(); void closePhotoCapture(); void updatePhotoCapture(); void importPhoto(); void openPhotoUpload(); void openPhotoAlbum(); void importPhotoAlbumEntry(std::size_t index); bool importUploadedPhoto(); void advancePhotoImport(); }; struct LoadingScope { App& app; explicit LoadingScope(App& target) : app(target) { app.beginLoading(); } ~LoadingScope() { app.endLoading(); } }; enum ButtonBits : std::uint32_t { BA = 1u << 0, BB = 1u << 1, BX = 1u << 2, BY = 1u << 3, BL = 1u << 4, BR = 1u << 5, BZL = 1u << 6, BZR = 1u << 7, BLEFT = 1u << 8, BRIGHT = 1u << 9, BUP = 1u << 10, BDOWN = 1u << 11, BPLUS = 1u << 12, BMINUS = 1u << 13, BLS = 1u << 14, BRS = 1u << 15, BMISC = 1u << 16 }; std::uint32_t controllerBit(std::uint8_t b) { switch (b) { // SDL names the standardized Xbox-position buttons; Nintendo labels // put A on the right, B on the bottom, X on top and Y on the left. case SDL_CONTROLLER_BUTTON_B: return BA; case SDL_CONTROLLER_BUTTON_A: return BB; case SDL_CONTROLLER_BUTTON_Y: return BX; case SDL_CONTROLLER_BUTTON_X: return BY; case SDL_CONTROLLER_BUTTON_LEFTSHOULDER: return BL; case SDL_CONTROLLER_BUTTON_RIGHTSHOULDER: return BR; case SDL_CONTROLLER_BUTTON_DPAD_LEFT: return BLEFT; case SDL_CONTROLLER_BUTTON_DPAD_RIGHT: return BRIGHT; case SDL_CONTROLLER_BUTTON_DPAD_UP: return BUP; case SDL_CONTROLLER_BUTTON_DPAD_DOWN: return BDOWN; case SDL_CONTROLLER_BUTTON_START: return BPLUS; case SDL_CONTROLLER_BUTTON_BACK: return BMINUS; case SDL_CONTROLLER_BUTTON_LEFTSTICK: return BLS; case SDL_CONTROLLER_BUTTON_RIGHTSTICK: return BRS; case SDL_CONTROLLER_BUTTON_MISC1: return BMISC; default: return 0; } } } // namespace int main(int argc, char** argv) { App app; if (!app.init(argc, argv)) { app.shutdown(); return 1; } app.run(); app.shutdown(); return 0; } namespace { bool App::init(int argc, char** argv) { logStartup("startup: entering init", true); #ifdef __SWITCH__ if (argc > 0 && argv && argv[0]) launchNroPath = argv[0]; logStartup("startup: loader NRO path=" + (launchNroPath.empty() ? std::string("") : launchNroPath)); assetsRoot = "romfs:"; dataRoot = "sdmc:/switch/Switchnote"; const Result romfsResult = romfsInit(); if (R_FAILED(romfsResult)) { logStartup("startup: romfsInit failed: " + std::to_string(static_cast(romfsResult))); return false; } romfsReady = true; logStartup("startup: romfs ready"); appletMode = appletGetAppletType() == AppletType_LibraryApplet; if (appletMode) logStartup("startup: LibraryApplet mode detected; showing compatibility warning"); else { // libnx mounts sdmc before main() as part of its default runtime startup. const Result csrngResult = csrngInitialize(); if (R_FAILED(csrngResult)) { logStartup("startup: csrngInitialize failed: " + std::to_string(static_cast(csrngResult))); return false; } csrngReady = true; const Result accountResult = accountInitialize(AccountServiceType_Application); logStartup("profile: accountInitialize=" + std::to_string(static_cast(accountResult))); if (R_SUCCEEDED(accountResult)) { accountReady = true; std::vector candidates; const auto addCandidate = [&candidates](const AccountUid& uid) { if (!accountUidIsValid(&uid)) return; const auto duplicate = std::find_if(candidates.begin(), candidates.end(), [&uid](const AccountUid& item) { return item.uid[0] == uid.uid[0] && item.uid[1] == uid.uid[1]; }); if (duplicate == candidates.end()) candidates.push_back(uid); }; AccountUid uid{}; const Result preselectedResult = accountGetPreselectedUser(&uid); if (R_SUCCEEDED(preselectedResult)) addCandidate(uid); logStartup("profile: preselected-user=" + std::to_string(static_cast(preselectedResult))); uid = {}; const Result lastOpenedResult = accountGetLastOpenedUser(&uid); if (R_SUCCEEDED(lastOpenedResult)) addCandidate(uid); logStartup("profile: last-opened-user=" + std::to_string(static_cast(lastOpenedResult))); uid = {}; const Result implicitResult = accountTrySelectUserWithoutInteraction(&uid, false); if (R_SUCCEEDED(implicitResult)) addCandidate(uid); logStartup("profile: implicit-user=" + std::to_string(static_cast(implicitResult))); std::array users{}; s32 actualUserCount = 0; const Result listResult = accountListAllUsers(users.data(), ACC_USER_LIST_SIZE, &actualUserCount); if (R_SUCCEEDED(listResult)) { for (s32 i = 0; i < std::clamp(actualUserCount, 0, ACC_USER_LIST_SIZE); ++i) addCandidate(users[static_cast(i)]); } logStartup("profile: user-list=" + std::to_string(static_cast(listResult)) + " count=" + std::to_string(actualUserCount)); for (std::size_t candidateIndex = 0; candidateIndex < candidates.size(); ++candidateIndex) { AccountProfile profile{}; const Result profileResult = accountGetProfile(&profile, candidates[candidateIndex]); if (R_FAILED(profileResult)) { logStartup("profile: get-profile failed candidate=" + std::to_string(candidateIndex) + " result=" + std::to_string(static_cast(profileResult))); continue; } AccountProfileBase profileBase{}; const Result profileDataResult = accountProfileGet(&profile, nullptr, &profileBase); if (R_FAILED(profileDataResult)) { logStartup("profile: profile-data failed candidate=" + std::to_string(candidateIndex) + " result=" + std::to_string(static_cast(profileDataResult))); accountProfileClose(&profile); continue; } profileBase.nickname[sizeof(profileBase.nickname) - 1] = '\0'; const bool nicknameFound = profileBase.nickname[0] != '\0'; if (nicknameFound) userName = profileBase.nickname; u32 imageSize = 0; const Result imageSizeResult = accountProfileGetImageSize(&profile, &imageSize); if (R_SUCCEEDED(imageSizeResult) && imageSize > 0 && imageSize <= 4U * 1024U * 1024U) { std::vector jpeg(imageSize); u32 loadedSize = 0; const Result imageLoadResult = accountProfileLoadImage(&profile, jpeg.data(), jpeg.size(), &loadedSize); if (R_SUCCEEDED(imageLoadResult) && loadedSize > 0 && loadedSize <= jpeg.size()) { jpeg.resize(loadedSize); profileImageBytes = std::move(jpeg); } else { logStartup("profile: image-load failed candidate=" + std::to_string(candidateIndex) + " result=" + std::to_string(static_cast(imageLoadResult))); } } else { logStartup("profile: image-size unavailable candidate=" + std::to_string(candidateIndex) + " result=" + std::to_string(static_cast(imageSizeResult)) + " size=" + std::to_string(imageSize)); } accountProfileClose(&profile); if (nicknameFound || !profileImageBytes.empty()) { logStartup("profile: loaded system profile candidate=" + std::to_string(candidateIndex) + (profileImageBytes.empty() ? " (nickname only)" : " (nickname and image)")); break; } } if (userName == "Switchnote User" && profileImageBytes.empty()) logStartup("profile: no usable system profile; using fallback"); } } #else assetsRoot = "romfs"; dataRoot = "data"; #endif if (!appletMode) { notesRoot = join(dataRoot, "switchnotes"); exportRoot = join(dataRoot, "exports"); importRoot = join(dataRoot, "import"); ensureDir(dataRoot); if (!migrateLegacyStorage(dataRoot)) { logStartup("startup: could not migrate existing Switchnote data"); return false; } ensureDir(notesRoot); ensureDir(exportRoot); ensureDir(importRoot); ensureDir(join(notesRoot, folder)); const std::string updatePrefix = join(join(dataRoot, "updates"), "Switchnote-"); const std::string updateSuffix = ".nro"; const std::string canonicalLaunchPath = canonicalSdPath(launchNroPath); const std::string canonicalUpdatePrefix = canonicalSdPath(updatePrefix); if (argc > 1 && argv && argv[1] && std::string(argv[1]) == "--switchnote-update-handoff" && canonicalLaunchPath.rfind(canonicalUpdatePrefix, 0) == 0 && canonicalLaunchPath.size() > canonicalUpdatePrefix.size() + updateSuffix.size() && canonicalLaunchPath.compare(canonicalLaunchPath.size() - updateSuffix.size(), updateSuffix.size(), updateSuffix) == 0 && validNroFile(launchNroPath)) { updateInstallPending = true; updateInstallSourcePath = launchNroPath; logStartup("update: launched verified NRO from update folder; install handoff armed"); } if (!loadIdentity()) { logStartup("startup: identity creation failed"); return false; } loadPreferences(); logStartup("startup: identity ready"); } SDL_SetHint(SDL_HINT_RENDER_SCALE_QUALITY, "linear"); const Uint32 sdlFlags = SDL_INIT_VIDEO | SDL_INIT_GAMECONTROLLER | SDL_INIT_JOYSTICK | (appletMode ? 0U : SDL_INIT_AUDIO); if (SDL_Init(sdlFlags) != 0) { logStartup("startup: SDL_Init failed: " + std::string(SDL_GetError())); return false; } sdlReady = true; const int imageFlags = IMG_Init(IMG_INIT_PNG | IMG_INIT_JPG); imageReady = true; if ((imageFlags & IMG_INIT_PNG) == 0) { logStartup("startup: SDL_image PNG initialization failed: " + std::string(IMG_GetError())); return false; } if (TTF_Init() != 0) { logStartup("startup: TTF_Init failed: " + std::string(TTF_GetError())); return false; } ttfReady = true; window = SDL_CreateWindow("Switchnote Studio", SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED, W, H, SDL_WINDOW_SHOWN | SDL_WINDOW_RESIZABLE); if (!window) { logStartup("startup: SDL_CreateWindow failed: " + std::string(SDL_GetError())); return false; } SDL_SetWindowTitle(window, appDisplayName.c_str()); renderer = SDL_CreateRenderer(window, -1, SDL_RENDERER_ACCELERATED | SDL_RENDERER_PRESENTVSYNC); if (!renderer) renderer = SDL_CreateRenderer(window, -1, SDL_RENDERER_SOFTWARE); if (!renderer) { logStartup("startup: SDL_CreateRenderer failed: " + std::string(SDL_GetError())); return false; } SDL_RenderSetLogicalSize(renderer, W, H); SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); if (appletMode) screen.active = screen.target = Screen::AppletWarning; // Decode the complete frog loop before the first real loading event. Lazy // PNG decoding here caused a hitch precisely when the overlay was needed. if (!appletMode) { for (std::size_t i = 0; i < loadingFrogFrames.size(); ++i) { const std::string customFrame = join(dataRoot, "branding-loading" + std::to_string(i) + ".png"); std::ifstream custom(customFrame, std::ios::binary); loadingFrogFrames[i] = custom ? loadBrandingLogo(customFrame) : image("ui/loading/frog" + std::to_string(i) + ".png"); } const std::string titleLogoPath = brandingLogoPath.empty() ? join(assetsRoot, "art/switchnotedefault.png") : brandingLogoPath; if (!titleLogoPath.empty()) { brandingLogoTexture = loadBrandingLogo(titleLogoPath); if (!brandingLogoTexture) logStartup("title logo could not be decoded: " + titleLogoPath + " :: " + IMG_GetError()); } } if (!profileImageBytes.empty() && profileImageBytes.size() <= static_cast(std::numeric_limits::max())) { SDL_RWops* stream = SDL_RWFromConstMem(profileImageBytes.data(), static_cast(profileImageBytes.size())); SDL_Surface* source = stream ? IMG_Load_RW(stream, 1) : nullptr; if (!source) logStartup("profile: JPEG decode failed: " + std::string(IMG_GetError())); auto createProfileTexture = [&](int edge) -> SDL_Texture* { SDL_Surface* avatar = source ? SDL_CreateRGBSurfaceWithFormat(0, edge, edge, 32, SDL_PIXELFORMAT_RGBA32) : nullptr; if (!avatar) return nullptr; if (SDL_BlitScaled(source, nullptr, avatar, nullptr) != 0) { SDL_FreeSurface(avatar); return nullptr; } if (SDL_MUSTLOCK(avatar)) SDL_LockSurface(avatar); constexpr float radius = 3.0f; for (int y = 0; y < avatar->h; ++y) { auto* row = reinterpret_cast(static_cast(avatar->pixels) + y * avatar->pitch); for (int x = 0; x < avatar->w; ++x) { const bool left = x < static_cast(radius); const bool right = x >= avatar->w - static_cast(radius); const bool top = y < static_cast(radius); const bool bottom = y >= avatar->h - static_cast(radius); if (!((left || right) && (top || bottom))) continue; const float cx = left ? radius : static_cast(avatar->w) - radius; const float cy = top ? radius : static_cast(avatar->h) - radius; const float dx = static_cast(x) + 0.5f - cx; const float dy = static_cast(y) + 0.5f - cy; const float edgeCoverage = std::clamp(radius + 0.5f - std::sqrt(dx * dx + dy * dy), 0.0f, 1.0f); std::uint8_t r, g, b, a; SDL_GetRGBA(row[x], avatar->format, &r, &g, &b, &a); a = static_cast(static_cast(a) * edgeCoverage + 0.5f); row[x] = SDL_MapRGBA(avatar->format, r, g, b, a); } } if (SDL_MUSTLOCK(avatar)) SDL_UnlockSurface(avatar); SDL_Texture* texture = SDL_CreateTextureFromSurface(renderer, avatar); SDL_FreeSurface(avatar); if (texture) { SDL_SetTextureScaleMode(texture, SDL_ScaleModeLinear); SDL_SetTextureBlendMode(texture, SDL_BLENDMODE_BLEND); } return texture; }; profileTexture = createProfileTexture(84); profileCornerTexture = createProfileTexture(58); profileDetailTexture = createProfileTexture(32); if (source && !profileTexture) logStartup("profile: avatar texture creation failed: " + std::string(SDL_GetError())); if (source) SDL_FreeSurface(source); } if (!appletMode && Mix_OpenAudio(48000, AUDIO_S16SYS, 2, 4096) == 0) { audioReady = true; Mix_AllocateChannels(52); const std::array, 24> soundMap{{ {"button_on", "BUTTON_ON"}, {"button_off", "BUTTON_OFF"}, {"cursor_cancel", "CURSOR_CANCEL"}, {"undo", "UNDO"}, {"redo", "REDO"}, {"page_new", "EDIT_PAGE_INSERT"}, {"movie_play", "MOVIE_PLAY"}, {"movie_stop", "MOVIE_STOP"}, {"draw_tool_btn_on", "DRAW_TOOL_BTN_ON"}, {"folder_open", "FOLDER_OPEN"}, {"pic_stamp", "PIC_STAMP"}, {"paint", "PAINT"}, {"page_backward", "PAGE_BACKWARD"}, {"page_forward", "PAGE_FORWARD"}, {"page_first", "PAGE_FIRST"}, {"page_end", "PAGE_END"}, {"edit_page_copy", "EDIT_PAGE_COPY"}, {"edit_page_paste", "EDIT_PAGE_PASTE"}, {"edit_page_remove", "EDIT_PAGE_REMOVE"}, {"screen_swap", "SCREEN_SWAP"}, {"popup", "POPUP"}, {"popup_select", "POPUP_SELECT"}, {"save_ok", "SAVE_OK"}, {"delete_complete", "DELETE_COMPLETE"} }}; int loadedCues = 0; for (const auto& cue : soundMap) { const std::string path = join(assetsRoot, std::string("sfx/") + cue.second + ".wav"); std::ifstream optionalCue(path, std::ios::binary); if (!optionalCue) continue; optionalCue.close(); cues[cue.first] = Mix_LoadWAV(path.c_str()); if (cues[cue.first]) ++loadedCues; else logStartup("audio cue load failed: " + path + " :: " + Mix_GetError()); } logStartup("audio ready: " + std::to_string(loadedCues) + "/" + std::to_string(soundMap.size()) + " cues loaded"); } else if (!appletMode) logStartup("audio open failed: " + std::string(Mix_GetError())); for (int i = 0; i < SDL_NumJoysticks(); ++i) { if (SDL_IsGameController(i)) { controller = SDL_GameControllerOpen(i); if (controller) break; } } if (!appletMode) { gallery.clear(); scanLibrary(); scanImports(); screen.beginTitleIntro(); if (updateInstallPending) updateInstallReadyAt = SDL_GetTicks() + 500; else startUpdateCheck(false); } else { screen.active = screen.target = Screen::AppletWarning; screen.phase = ScreenState::Phase::Idle; } logStartup("startup: initialization complete"); return true; } void App::shutdown() { if (updateThread) { SDL_WaitThread(updateThread, nullptr); updateThread = nullptr; } #ifdef __SWITCH__ if (serverStartThreadCreated) { threadWaitForExit(&serverStartThread); threadClose(&serverStartThread); serverStartThreadCreated = false; } if (galleryLoadThreadCreated) { threadWaitForExit(&galleryLoadThread); threadClose(&galleryLoadThread); galleryLoadThreadCreated = false; } if (photoProbeThreadCreated) { threadWaitForExit(&photoProbeThread); threadClose(&photoProbeThread); photoProbeThreadCreated = false; } #endif if (dirty && docLoaded) saveDocument(); stopAudioUploadServer(); stopBrandingConfigServer(); stopPlayback(); closePhotoCapture(); #ifdef __SWITCH__ if (irPreviewTexture) SDL_DestroyTexture(irPreviewTexture); for (SDL_Texture* texture : photoAlbumThumbs) if (texture) SDL_DestroyTexture(texture); #endif for (SDL_Texture* texture : loadingFrogFrames) if (texture) SDL_DestroyTexture(texture); for (auto& it : frameTextures) SDL_DestroyTexture(it.second.texture); frameTextures.clear(); if (commentTexture) SDL_DestroyTexture(commentTexture); if (profileTexture) SDL_DestroyTexture(profileTexture); if (profileCornerTexture) SDL_DestroyTexture(profileCornerTexture); if (profileDetailTexture) SDL_DestroyTexture(profileDetailTexture); if (galleryProfileAvatarTexture) SDL_DestroyTexture(galleryProfileAvatarTexture); if (brandingLogoTexture) SDL_DestroyTexture(brandingLogoTexture); for (auto& it : galleryCommentTextures) if (it.second) SDL_DestroyTexture(it.second); galleryCommentTextures.clear(); for (auto& it : images) SDL_DestroyTexture(it.second); for (auto& it : whiteImages) if (it.second) SDL_DestroyTexture(it.second); for (auto& it : textCache) SDL_DestroyTexture(it.second); for (auto& it : fonts) TTF_CloseFont(it.second); for (auto& it : cues) if (it.second) Mix_FreeChunk(it.second); for (auto* chunk : trackChunks) if (chunk) Mix_FreeChunk(chunk); if (controller) SDL_GameControllerClose(controller); if (audioReady) Mix_CloseAudio(); if (renderer) SDL_DestroyRenderer(renderer); if (window) SDL_DestroyWindow(window); if (ttfReady) TTF_Quit(); if (imageReady) IMG_Quit(); if (sdlReady) SDL_Quit(); #ifdef __SWITCH__ if (gallerySslReady) sslExit(); if (gallerySocketsReady) socketExit(); if (nifmReady) nifmExit(); if (accountReady) accountExit(); if (csrngReady) csrngExit(); if (romfsReady) romfsExit(); #endif } void App::beginLoading() { if (loadingDepth++ == 0) { loadingStartedAt = SDL_GetTicks(); loadingCompletionVisible = false; } } void App::endLoading() { if (loadingDepth <= 0) return; if (--loadingDepth == 0) { completedLoadingDuration = SDL_GetTicks() - loadingStartedAt; if (completedLoadingDuration >= 1000) { loadingFinishedAt = SDL_GetTicks(); loadingCompletionVisible = true; } } } void App::renderLoadingOverlay() { const std::uint32_t now = SDL_GetTicks(); if (photoModalTransitionDim) { const std::uint32_t transitionElapsed = now - photoModalTransitionDimAt; if (transitionElapsed < 125) { const Uint8 alpha = static_cast(std::lround(72.0f * (1.0f - transitionElapsed / 125.0f))); fill(0, 0, W, H, SDL_Color{18, 20, 28, alpha}); } else photoModalTransitionDim = false; } if (loadingDepth <= 0 && !loadingCompletionVisible) return; SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); const bool completed = loadingDepth <= 0; const std::uint32_t completionElapsed = completed ? now - loadingFinishedAt : 0; const std::uint32_t elapsed = completed ? completedLoadingDuration : now - loadingStartedAt; if (!updateInProgress && elapsed < 1000) return; if (completed && completionElapsed >= 800) { loadingCompletionVisible = false; return; } // Keep the scene undimmed while a task is merely pending. Once the frog // appears, dim behind it so loading feedback is visible without obscuring // ordinary modal interactions. fill(0, 0, W, H, SDL_Color{18, 20, 28, 72}); constexpr std::uint32_t frameMs = 90; const std::size_t frameIndex = static_cast((now / frameMs) % loadingFrogFrames.size()); float progress = updateInProgress ? 1.0f : std::clamp((elapsed - 1000) / 220.0f, 0.0f, 1.0f); if (completed) { progress = completionElapsed < 200 ? completionElapsed / 200.0f : completionElapsed < 600 ? 1.0f : 1.0f - (completionElapsed - 600) / 200.0f; } const float eased = 1.0f - std::pow(1.0f - progress, 3.0f); SDL_Texture* frog = loadingFrogFrames[frameIndex]; if (!frog) { const std::string asset = "ui/loading/frog" + std::to_string(frameIndex) + ".png"; frog = image(asset); loadingFrogFrames[frameIndex] = frog; } if (frog) { int sourceW = 0, sourceH = 0; SDL_QueryTexture(frog, nullptr, nullptr, &sourceW, &sourceH); const float scale = sourceW > 0 && sourceH > 0 ? std::min(64.0f / sourceW, 64.0f / sourceH) : 1.0f; const int drawW = std::max(1, static_cast(std::lround(sourceW * scale))); const int drawH = std::max(1, static_cast(std::lround(sourceH * scale))); const int x = W - drawW + static_cast(std::lround((1.0f - eased) * drawW)); drawTexture(frog, x, H - drawH - 14, drawW, drawH); } } bool App::loadIdentity() { std::vector hardware; if (!getDeviceMaterial(hardware)) { logStartup("identity: no console-specific device identifier is available"); return false; } // Keep old HMAC-signed .sppm files readable, but move this compatibility // key into the new authenticated, device-wrapped identity record. std::array legacyKey{}; const std::string legacyPath = join(dataRoot, "identity.bin"); std::vector oldIdentity; if (readBinaryFile(legacyPath, oldIdentity) && oldIdentity.size() == 48) { std::copy_n(oldIdentity.begin(), legacyKey.size(), legacyKey.begin()); } bool found = false; bool legacyKeyIsWrapped = false; if (DIR* dir = opendir(dataRoot.c_str())) { while (dirent* entry = readdir(dir)) { const std::string name = entry->d_name; if (name.rfind("identity-v2-", 0) != 0 || !endsWith(name, ".bin")) continue; if (loadEncryptedIdentity(join(dataRoot, name), hardware, signingIdentity)) { found = true; break; } } closedir(dir); } if (!found) { std::string identityPath; if (!createEncryptedIdentity(dataRoot, hardware, legacyKey, signingIdentity, identityPath)) { logStartup("identity: could not create encrypted console-bound signing identity"); return false; } sppm::SigningIdentity check{}; if (!loadEncryptedIdentity(identityPath, hardware, check) || check.secretKey != signingIdentity.secretKey || check.legacyHmacKey != signingIdentity.legacyHmacKey) { std::remove(identityPath.c_str()); logStartup("identity: encrypted key could not be reopened after writing"); return false; } signingIdentity = check; legacyKeyIsWrapped = true; } else { legacyKeyIsWrapped = signingIdentity.legacyHmacKey == legacyKey; if (std::all_of(signingIdentity.legacyHmacKey.begin(), signingIdentity.legacyHmacKey.end(), [](std::uint8_t value) { return value == 0; }) && std::any_of(legacyKey.begin(), legacyKey.end(), [](std::uint8_t value) { return value != 0; })) { // Recover a pre-v2 profile created by an earlier build if it has // the same persisted local key. Keep its plaintext source until // a future encrypted migration can be safely confirmed. signingIdentity.legacyHmacKey = legacyKey; } } myFsid = sppm::deriveFsid(signingIdentity.publicIdentity); if (oldIdentity.size() == 48 && legacyKeyIsWrapped) { // The compatibility key has already been authenticated by the new // device-bound encrypted record, so the old plaintext copy is no // longer needed. std::remove(legacyPath.c_str()); } #ifndef __SWITCH__ std::ifstream nameFile(join(dataRoot, "profile.txt"), std::ios::binary); if (nameFile) { std::getline(nameFile, userName); if (userName.empty() || userName.size() > 48) userName = "Switchnote User"; } #endif return true; } std::string App::newId(const char* domain) { std::array idSecret{}; std::array nonce{}; if (!randomBytes(idSecret.data(), idSecret.size()) || !randomBytes(nonce.data(), nonce.size())) return {}; return sppm::deriveFsid(idSecret, nonce, domain); } void App::scanLibrary() { library.clear(); const std::string currentDir = join(notesRoot, folder); ensureDir(currentDir); if (DIR* dir = opendir(currentDir.c_str())) { while (dirent* entry = readdir(dir)) { const std::string name = entry->d_name; const std::size_t marker = name.find(".sppm.bak-"); if (marker == std::string::npos) continue; const std::string target = join(currentDir, name.substr(0, marker + 5)); struct stat targetStat{}; if (stat(target.c_str(), &targetStat) != 0) { const std::string backup = join(currentDir, name); if (std::rename(backup.c_str(), target.c_str()) == 0) logStartup("recovered previous Switchnote save: " + target); } } closedir(dir); } if (DIR* dir = opendir(currentDir.c_str())) { while (dirent* entry = readdir(dir)) { std::string name = entry->d_name; if (!endsWith(name, ".sppm")) continue; sppm::Document file; std::string error; const std::string path = join(currentDir, name); if (sppm::loadSignedFile(path, file, signingIdentity.legacyHmacKey, error)) { Item item{}; item.title = file.metadata.title.empty() ? defaultSwitchnoteTitle(userName, noteSingular) : file.metadata.title; item.path = path; item.author = file.metadata.authorName; item.description = file.metadata.description; item.originalAuthor = file.metadata.originalAuthorName; item.authorFsid = file.metadata.authorFsid; item.originalAuthorFsid = file.metadata.originalAuthorFsid; item.currentId = file.metadata.currentId; item.rootId = file.metadata.rootId; item.createdUnixSeconds = file.metadata.createdUnixSeconds; item.modifiedUnixSeconds = file.metadata.modifiedUnixSeconds; item.allowSpinoffs = file.metadata.allowSpinoffs; item.locked = file.metadata.locked; item.ownerIdentity = sppm::identityFingerprint(file.metadata.authorIdentity); library.push_back(std::move(item)); } } closedir(dir); } std::sort(library.begin(), library.end(), [](const Item& a, const Item& b) { if (a.modifiedUnixSeconds != b.modifiedUnixSeconds) return a.modifiedUnixSeconds > b.modifiedUnixSeconds; if (a.createdUnixSeconds != b.createdUnixSeconds) return a.createdUnixSeconds > b.createdUnixSeconds; return a.path < b.path; }); if (library.empty()) selected = 0; else selected = std::min(selected, library.size() - 1); page = static_cast(selected / 6); } void App::scanImports() { importFiles.clear(); if (DIR* dir = opendir(importRoot.c_str())) { while (dirent* entry = readdir(dir)) { const std::string name = entry->d_name; if (endsWith(name, ".wav") || endsWith(name, ".WAV")) importFiles.push_back(name); } closedir(dir); } std::sort(importFiles.begin(), importFiles.end()); importSelection = 0; } bool App::startAudioUploadServer(bool photoTransfer) { #ifdef __SWITCH__ if (audioUploadServer.isRunning()) return photoTransferActive == photoTransfer; if (!gallerySocketsReady) { const Result sockets = socketInitializeDefault(); if (R_FAILED(sockets)) { audioUploadStatus = "Network initialization failed."; return false; } gallerySocketsReady = true; } if (!nifmReady) { const Result network = nifmInitialize(NifmServiceType_User); if (R_FAILED(network)) { audioUploadStatus = "Could not read the Switch network address."; return false; } nifmReady = true; } u32 rawAddress = 0; if (R_FAILED(nifmGetCurrentIpAddress(&rawAddress))) { audioUploadStatus = "Connect the Switch to your local Wi-Fi first."; return false; } const std::uint32_t hostAddress = ntohl(rawAddress); const bool privateAddress = (hostAddress >> 24) == 10 || (hostAddress & 0xfff00000U) == 0xac100000U || (hostAddress & 0xffff0000U) == 0xc0a80000U || (hostAddress & 0xffff0000U) == 0xa9fe0000U || (hostAddress & 0xffc00000U) == 0x64400000U; if (!privateAddress) { audioUploadStatus = "Connect to a private local network to upload audio."; return false; } in_addr address{}; address.s_addr = rawAddress; char addressText[INET_ADDRSTRLEN]{}; if (!::inet_ntop(AF_INET, &address, addressText, sizeof(addressText))) { audioUploadStatus = "Could not format the Switch network address."; return false; } // Rejection sampling keeps every six-digit code equally likely. std::uint32_t randomValue = 0; do { if (!randomBytes(&randomValue, sizeof(randomValue))) { audioUploadStatus = "Could not create a secure upload code."; return false; } } while (randomValue >= 4294000000U); const unsigned int pin = randomValue % 1000000U; char formattedCode[8]{}; std::snprintf(formattedCode, sizeof(formattedCode), "%03u-%03u", pin / 1000U, pin % 1000U); const std::string code = formattedCode; std::string error; if (!audioUploadServer.start(addressText, importRoot, assetsRoot, code, error, photoTransfer, noteSingular)) { audioUploadStatus = error.empty() ? "Could not start the local upload page." : error; return false; } audioUploadCode = code; audioUploadUrl = "http://" + std::string(addressText) + ":8766"; photoTransferActive = photoTransfer; audioUploadStatus = "Enter the code on your PC."; return true; #else (void)photoTransfer; audioUploadStatus = "The upload server is available in the Switch homebrew app."; return false; #endif } void App::requestServerStart(ServerStartTask task) { if (serverStartRunning || task == ServerStartTask::None) return; serverStartTask = task; serverStartRunning = true; serverStartSucceeded = false; serverStartFinished.store(false, std::memory_order_relaxed); beginLoading(); #ifdef __SWITCH__ if (R_FAILED(threadCreate(&serverStartThread, serverStartThreadEntry, this, nullptr, 0x10000, 0x3B, -2))) { serverStartRunning = false; serverStartTask = ServerStartTask::None; endLoading(); showToast("Could not start the transfer service."); return; } serverStartThreadCreated = true; if (R_FAILED(threadStart(&serverStartThread))) { threadClose(&serverStartThread); serverStartThreadCreated = false; serverStartRunning = false; serverStartTask = ServerStartTask::None; endLoading(); showToast("Could not start the transfer service."); } #else serverStartSucceeded = task == ServerStartTask::Branding ? startBrandingConfigServer() : startAudioUploadServer(task == ServerStartTask::Photo); serverStartFinished.store(true, std::memory_order_release); #endif } void App::requestAudioUploadServer(bool photoTransfer) { requestServerStart(photoTransfer ? ServerStartTask::Photo : ServerStartTask::Audio); } void App::requestBrandingConfigServer() { requestServerStart(ServerStartTask::Branding); } #ifdef __SWITCH__ void App::serverStartThreadEntry(void* argument) { auto* app = static_cast(argument); app->runServerStartThread(); threadExit(); } void App::runServerStartThread() { serverStartSucceeded = serverStartTask == ServerStartTask::Branding ? startBrandingConfigServer() : startAudioUploadServer(serverStartTask == ServerStartTask::Photo); serverStartFinished.store(true, std::memory_order_release); } #endif void App::consumeServerStart() { if (!serverStartRunning || !serverStartFinished.exchange(false, std::memory_order_acquire)) return; #ifdef __SWITCH__ if (serverStartThreadCreated) { threadWaitForExit(&serverStartThread); threadClose(&serverStartThread); serverStartThreadCreated = false; } #endif const ServerStartTask completedTask = serverStartTask; serverStartRunning = false; serverStartTask = ServerStartTask::None; endLoading(); if (serverStartSucceeded) { if (completedTask == ServerStartTask::Branding) showToast("Branding portal is ready on your local network"); else showToast(completedTask == ServerStartTask::Photo ? "Photo upload page is ready on your local network" : "Sound upload page is ready on your local network"); } else { showToast(completedTask == ServerStartTask::Branding ? brandingStatus : audioUploadStatus); } } void App::stopAudioUploadServer() { audioUploadServer.stop(); photoTransferActive = false; audioUploadUrl.clear(); audioUploadCode.clear(); } void App::closeAudioMenu() { consumeAudioUpload(); if (audioReady) Mix_HaltChannel(4); stopAudioUploadServer(); audioEraseArmedTrack = -1; modal = Modal::None; } void App::consumeAudioUpload() { int track = -1; if (!audioUploadServer.takeCompletedUpload(track)) return; if (track == 4) { if (photoImporting) return; const bool started = importUploadedPhoto(); if (!started) { std::remove(join(importRoot, "photo-transfer.rgba").c_str()); std::remove(join(importRoot, "photo-transfer.cfg").c_str()); audioUploadServer.finishCompletedUpload(false); audioUploadStatus = "That image could not be imported."; } else audioUploadStatus = "Adding image to your Switchnote…"; return; } const std::string path = join(importRoot, "audio-transfer-" + std::to_string(track) + ".wav"); const std::string name = track == 0 ? "BGM" : "SE" + std::to_string(track); const bool imported = importAudioFile(path, track, name + " from PC"); audioEraseArmedTrack = -1; std::remove(path.c_str()); audioUploadServer.finishCompletedUpload(imported); if (imported) audioUploadStatus = name + " added from your PC."; else audioUploadStatus = "That WAV could not be added. Check its duration and format."; } bool App::galleryRequest(const std::string& method, const std::string& path, const std::vector& requestBody, const std::string& contentType, std::vector& responseBody, int& status, std::string& error, std::string* responseHeaders) { responseBody.clear(); status = 0; if (responseHeaders) responseHeaders->clear(); #ifdef __SWITCH__ if (!gallerySocketsReady) { const Result result = socketInitializeDefault(); if (R_FAILED(result)) { error = "Network setup failed (" + std::to_string(static_cast(result)) + ")"; return false; } gallerySocketsReady = true; } if (!gallerySslReady) { const Result result = sslInitialize(1); if (R_FAILED(result)) { error = "Secure Gallery networking could not be initialized (" + std::to_string(static_cast(result)) + ")"; return false; } gallerySslReady = true; } std::string authHeaders; if ((method == "POST" && path != "/api/flipnotes") || method == "DELETE") { std::vector challengeResponse; int challengeStatus = 0; std::string authError; if (!galleryRequest("GET", "/api/auth/challenge", {}, "text/plain", challengeResponse, challengeStatus, authError)) { error = "Gallery authentication challenge failed: " + authError; return false; } std::string nonceHex(challengeResponse.begin(), challengeResponse.end()); while (!nonceHex.empty() && (nonceHex.back() == '\r' || nonceHex.back() == '\n')) nonceHex.pop_back(); std::array nonce{}; if (!hexDecode(nonceHex, nonce.data(), nonce.size())) { error = "Gallery returned an invalid authentication challenge"; return false; } std::vector signedRequest; const std::string domain = "FlipnoteNX-Gallery-Request-v1"; signedRequest.insert(signedRequest.end(), domain.begin(), domain.end()); signedRequest.push_back(0); signedRequest.insert(signedRequest.end(), method.begin(), method.end()); signedRequest.push_back(0); signedRequest.insert(signedRequest.end(), path.begin(), path.end()); signedRequest.push_back(0); signedRequest.insert(signedRequest.end(), nonce.begin(), nonce.end()); signedRequest.push_back(0); signedRequest.insert(signedRequest.end(), myFsid.begin(), myFsid.end()); signedRequest.push_back(0); signedRequest.insert(signedRequest.end(), requestBody.begin(), requestBody.end()); std::array signature{}; if (!sppm::signMessage(signingIdentity, signedRequest.empty() ? nullptr : signedRequest.data(), signedRequest.size(), signature, authError)) { error = "Could not authenticate Gallery action: " + authError; return false; } authHeaders = "X-FSNX-Nonce: " + nonceHex + "\r\n" + "X-FSNX-FSID: " + myFsid + "\r\n" + "X-FSNX-Key: " + hexEncode(signingIdentity.publicIdentity.key.data(), signingIdentity.publicIdentity.key.size()) + "\r\n" + "X-FSNX-Device: " + hexEncode(signingIdentity.publicIdentity.deviceTag.data(), signingIdentity.publicIdentity.deviceTag.size()) + "\r\n" + "X-FSNX-Signature: " + hexEncode(signature.data(), signature.size()) + "\r\n"; std::fill(signedRequest.begin(), signedRequest.end(), 0); } addrinfo hints{}; hints.ai_family = AF_INET; hints.ai_socktype = SOCK_STREAM; addrinfo* addresses = nullptr; const std::string service = std::to_string(kGalleryServerPort); if (::getaddrinfo(kGalleryServerHost, service.c_str(), &hints, &addresses) != 0 || !addresses) { error = "Could not resolve the secure Gallery address " + std::string(kGalleryServerHost); if (addresses) ::freeaddrinfo(addresses); return false; } int fd = -1; for (addrinfo* candidate = addresses; candidate; candidate = candidate->ai_next) { fd = ::socket(candidate->ai_family, candidate->ai_socktype, candidate->ai_protocol); if (fd < 0) continue; timeval timeout{}; timeout.tv_sec = 6; ::setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)); ::setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout)); if (::connect(fd, candidate->ai_addr, candidate->ai_addrlen) == 0) break; ::close(fd); fd = -1; } ::freeaddrinfo(addresses); if (fd < 0) { error = "Could not reach the secure Gallery at https://" + std::string(kGalleryServerHost) + "/nswitch/"; return false; } SslContext sslContext{}; SslConnection sslConnection{}; bool contextCreated = false; bool connectionCreated = false; bool socketAttached = false; int sslSocketToClose = -1; auto closeTls = [&]() { if (connectionCreated) { sslConnectionClose(&sslConnection); connectionCreated = false; } if (contextCreated) { sslContextClose(&sslContext); contextCreated = false; } if (sslSocketToClose >= 0) { ::close(sslSocketToClose); sslSocketToClose = -1; } else if (!socketAttached && fd >= 0) { ::close(fd); } fd = -1; }; const char* tlsStage = "TLS context creation"; Result tlsResult = sslCreateContext(&sslContext, SslVersion_TlsV12 | SslVersion_TlsV13); contextCreated = R_SUCCEEDED(tlsResult); if (R_SUCCEEDED(tlsResult)) { tlsStage = "TLS connection setup"; tlsResult = sslContextCreateConnection(&sslContext, &sslConnection); connectionCreated = R_SUCCEEDED(tlsResult); } if (R_SUCCEEDED(tlsResult)) { tlsStage = "Gallery hostname setup"; tlsResult = sslConnectionSetHostName(&sslConnection, kGalleryServerHost, std::strlen(kGalleryServerHost)); } if (R_SUCCEEDED(tlsResult)) { tlsStage = "certificate verification setup"; tlsResult = sslConnectionSetVerifyOption(&sslConnection, SslVerifyOption_PeerCa | SslVerifyOption_HostName | SslVerifyOption_DateCheck); } if (R_SUCCEEDED(tlsResult)) { tlsStage = "secure socket setup"; errno = 0; sslSocketToClose = socketSslConnectionSetSocketDescriptor(&sslConnection, fd); socketAttached = true; if (sslSocketToClose < 0 && errno != ENOENT) tlsResult = MAKERESULT(Module_Libnx, LibnxError_BadInput); } if (R_SUCCEEDED(tlsResult)) { tlsStage = "TLS handshake/certificate verification"; tlsResult = sslConnectionDoHandshake(&sslConnection, nullptr, nullptr, nullptr, 0); } if (R_FAILED(tlsResult)) { std::ostringstream detail; detail << "Secure Gallery connection failed during " << tlsStage << " (" << static_cast(tlsResult) << ", module " << R_MODULE(tlsResult) << ", description " << R_DESCRIPTION(tlsResult) << ")"; if (connectionCreated && std::strcmp(tlsStage, "TLS handshake/certificate verification") == 0) { const Result verifyResult = sslConnectionGetVerifyCertError(&sslConnection); if (R_FAILED(verifyResult)) { detail << " [certificate check " << static_cast(verifyResult) << ", module " << R_MODULE(verifyResult) << ", description " << R_DESCRIPTION(verifyResult) << "]"; } else { detail << " [no certificate rejection reported]"; } } error = detail.str(); closeTls(); return false; } const std::string type = contentType.empty() ? "application/octet-stream" : contentType; const std::string requestPath = std::string(kGalleryServerBasePath) + path; const auto requestNumber = galleryRequestCounter.fetch_add(1, std::memory_order_relaxed) + 1; const std::string requestId = "snx-" + std::to_string(SDL_GetTicks()) + "-" + std::to_string(requestNumber); const std::string headers = method + " " + requestPath + " HTTP/1.1\r\nHost: " + kGalleryServerHost + "\r\nUser-Agent: SwitchnoteStudio/0.1.51\r\nX-Switchnote-Client: Switchnote-Studio\r\nX-Switchnote-Request-ID: " + requestId + "\r\nConnection: close\r\nContent-Type: " + type + "\r\nContent-Length: " + std::to_string(requestBody.size()) + "\r\n" + authHeaders + "\r\n"; auto sendBytes = [&sslConnection](const std::uint8_t* data, std::size_t length) { std::size_t sent = 0; while (sent < length) { u32 written = 0; const std::size_t amount = std::min(length - sent, 0x4000); const Result result = sslConnectionWrite(&sslConnection, data + sent, static_cast(amount), &written); if (R_FAILED(result) || written == 0) return false; sent += written; } return true; }; if (!sendBytes(reinterpret_cast(headers.data()), headers.size()) || (!requestBody.empty() && !sendBytes(requestBody.data(), requestBody.size()))) { error = "Could not send the Gallery request"; closeTls(); return false; } std::vector received; received.reserve(4096); std::array chunk{}; constexpr std::size_t maxResponse = 72U * 1024U * 1024U; while (true) { u32 count = 0; const Result result = sslConnectionRead(&sslConnection, chunk.data(), chunk.size(), &count); if (R_FAILED(result)) { error = "Gallery response timed out or was interrupted"; closeTls(); return false; } if (count == 0) break; if (received.size() + count > maxResponse) { error = "Gallery response is too large"; closeTls(); return false; } received.insert(received.end(), chunk.begin(), chunk.begin() + count); } closeTls(); if (received.empty()) { error = "Gallery returned an empty response"; return false; } const std::string headerText(reinterpret_cast(received.data()), received.size()); const std::size_t headerEnd = headerText.find("\r\n\r\n"); if (headerEnd == std::string::npos) { error = "Gallery returned an invalid HTTP response"; return false; } const std::size_t statusEnd = headerText.find("\r\n"); if (statusEnd == std::string::npos) { error = "Gallery returned an invalid HTTP status"; return false; } const std::string statusLine = headerText.substr(0, statusEnd); if (std::sscanf(statusLine.c_str(), "HTTP/%*s %d", &status) != 1) { error = "Gallery returned an invalid HTTP status"; return false; } const std::size_t bodyStart = headerEnd + 4; if (responseHeaders) responseHeaders->assign(headerText.data(), headerEnd); responseBody.assign(received.begin() + static_cast(bodyStart), received.end()); if (status < 200 || status >= 300) { error.assign(responseBody.begin(), responseBody.begin() + static_cast(std::min(responseBody.size(), 300))); while (!error.empty() && (error.back() == '\r' || error.back() == '\n')) error.pop_back(); if (error.empty()) error = "Gallery returned HTTP " + std::to_string(status); return false; } error.clear(); return true; #else (void)method; (void)path; (void)requestBody; (void)contentType; (void)responseHeaders; error = "Switchnote Gallery is available from the Switch app"; return false; #endif } void App::enterGallery() { galleryPage = 0; gallerySelected = 0; galleryStatus = "Loading Gallery…"; screen = Screen::Gallery; refreshGallery(); } void App::showGalleryNotice() { modal = Modal::GalleryNotice; modalTransitionStart = SDL_GetTicks(); } bool App::internetConnected() { #ifdef __SWITCH__ if (!nifmReady) { if (R_FAILED(nifmInitialize(NifmServiceType_User))) return false; nifmReady = true; } NifmInternetConnectionType connectionType{}; NifmInternetConnectionStatus connectionStatus{}; u32 wifiStrength = 0; return R_SUCCEEDED(nifmGetInternetConnectionStatus(&connectionType, &wifiStrength, &connectionStatus)) && connectionStatus == NifmInternetConnectionStatus_Connected; #else return true; #endif } void App::confirmGalleryLeave() { if (internetConnected()) { modal = Modal::None; enterGallery(); } else { modal = Modal::GalleryOffline; modalTransitionStart = SDL_GetTicks(); } } void App::refreshGallery() { if (galleryLoadRunning) return; galleryLoadKind = GalleryLoadKind::Feed; const std::string path = "/api/flipnotes?page=" + std::to_string(galleryPage) + "&sort=" + (galleryPopular ? "popular" : "recent"); galleryStatus = "Loading Gallery…"; #ifdef __SWITCH__ if (galleryLoadThreadCreated) { threadWaitForExit(&galleryLoadThread); threadClose(&galleryLoadThread); galleryLoadThreadCreated = false; } galleryLoadPath = path; galleryLoadFinished.store(false, std::memory_order_relaxed); galleryLoadRunning = true; galleryLoadResponse.clear(); galleryLoadError.clear(); beginLoading(); if (R_FAILED(threadCreate(&galleryLoadThread, galleryLoadThreadEntry, this, nullptr, 0x10000, 0x3B, -2))) { galleryLoadRunning = false; endLoading(); galleryStatus = "Could not start the Gallery connection."; showToast(galleryStatus); return; } galleryLoadThreadCreated = true; if (R_FAILED(threadStart(&galleryLoadThread))) { threadClose(&galleryLoadThread); galleryLoadThreadCreated = false; galleryLoadRunning = false; endLoading(); galleryStatus = "Could not start the Gallery connection."; showToast(galleryStatus); } #else std::vector response; int status = 0; std::string error; if (!galleryRequest("GET", path, {}, "text/plain", response, status, error)) { finishGalleryRefresh(response, status, error); return; } finishGalleryRefresh(response, status, {}); #endif } void App::loadGalleryProfile(const std::string& identity, Screen returnScreen, int pageIndex) { if (identity.size() != 64) { showToast("This Gallery user has no profile yet"); return; } if (galleryLoadRunning) return; if (!galleryProfileActive) { galleryBeforeProfile = gallery; gallerySelectedBeforeProfile = gallerySelected; galleryPageBeforeProfile = galleryPage; galleryTotalBeforeProfile = galleryTotal; galleryProfileActive = true; } if (galleryProfile.identity != identity) galleryProfile = GalleryProfileData{}; galleryProfile.identity = identity; galleryProfileReturnScreen = returnScreen; galleryProfilePage = std::max(0, pageIndex); galleryProfileDetailMode = false; galleryProfileIsOwn = identity == sppm::identityFingerprint(signingIdentity.publicIdentity); galleryLoadKind = GalleryLoadKind::Profile; galleryLoadPath = "/api/profiles/" + identity + "?viewer=" + sppm::identityFingerprint(signingIdentity.publicIdentity); if (galleryProfilePage > 0) galleryLoadPath += "&page=" + std::to_string(galleryProfilePage - 1); gallery.clear(); screen = Screen::GalleryProfile; galleryLoadFinished.store(false, std::memory_order_relaxed); galleryLoadRunning = true; galleryLoadResponse.clear(); galleryLoadError.clear(); beginLoading(); #ifdef __SWITCH__ if (galleryLoadThreadCreated) { threadWaitForExit(&galleryLoadThread); threadClose(&galleryLoadThread); galleryLoadThreadCreated = false; } if (R_FAILED(threadCreate(&galleryLoadThread, galleryLoadThreadEntry, this, nullptr, 0x10000, 0x3B, -2))) { galleryLoadRunning = false; endLoading(); galleryStatus = "Could not start the Gallery connection."; return; } galleryLoadThreadCreated = true; if (R_FAILED(threadStart(&galleryLoadThread))) { threadClose(&galleryLoadThread); galleryLoadThreadCreated = false; galleryLoadRunning = false; endLoading(); galleryStatus = "Could not start the Gallery connection."; } #else int status = 0; std::string error; const bool ok = galleryRequest("GET", galleryLoadPath, {}, "text/plain", galleryLoadResponse, status, error); galleryLoadRunning = false; endLoading(); finishGalleryProfile(galleryLoadResponse, status, ok ? std::string{} : error); #endif } void App::leaveGalleryProfile() { if (galleryProfileDetailMode) { galleryProfileDetailMode = false; screen = Screen::GalleryProfile; return; } if (galleryProfileActive) { gallery = std::move(galleryBeforeProfile); gallerySelected = gallery.empty() ? 0 : std::min(gallerySelectedBeforeProfile, gallery.size() - 1); galleryPage = galleryPageBeforeProfile; galleryTotal = galleryTotalBeforeProfile; galleryProfileActive = false; } screen = galleryProfileReturnScreen; } #ifdef __SWITCH__ void App::galleryLoadThreadEntry(void* argument) { auto* app = static_cast(argument); app->runGalleryLoadThread(); threadExit(); } void App::runGalleryLoadThread() { galleryLoadSucceeded = galleryRequest("GET", galleryLoadPath, {}, "text/plain", galleryLoadResponse, galleryLoadHttpStatus, galleryLoadError); galleryLoadFinished.store(true, std::memory_order_release); } #endif void App::consumeGalleryLoad() { #ifdef __SWITCH__ if (!galleryLoadRunning || !galleryLoadFinished.exchange(false, std::memory_order_acquire)) return; if (galleryLoadThreadCreated) { threadWaitForExit(&galleryLoadThread); threadClose(&galleryLoadThread); galleryLoadThreadCreated = false; } galleryLoadRunning = false; endLoading(); if (galleryLoadKind == GalleryLoadKind::Profile) finishGalleryProfile(galleryLoadResponse, galleryLoadHttpStatus, galleryLoadSucceeded ? std::string{} : galleryLoadError); else finishGalleryRefresh(galleryLoadResponse, galleryLoadHttpStatus, galleryLoadSucceeded ? std::string{} : galleryLoadError); #endif } void App::finishGalleryProfile(const std::vector& response, int status, const std::string& error) { (void)status; if (!error.empty()) { galleryStatus = error; showToast(error); return; } std::string body(response.begin(), response.end()); if (!body.empty() && body.back() == '\n') body.pop_back(); std::istringstream lines(body); std::string line; if (!std::getline(lines, line)) line.clear(); const auto fields = splitTabs(line); if (fields.size() < 11 || fields[0] != "PROFILE") { galleryStatus = "Gallery returned an invalid profile."; showToast(galleryStatus); return; } galleryProfile.username = percentDecode(fields[1]); galleryProfile.bio = percentDecode(fields[2]); galleryProfile.switchnotes = std::max(0, std::atoi(fields[3].c_str())); galleryProfile.views = std::max(0, std::atoi(fields[4].c_str())); galleryProfile.coins = std::max(0, std::atoi(fields[5].c_str())); galleryProfile.followers = std::max(0, std::atoi(fields[6].c_str())); galleryProfile.following = std::max(0, std::atoi(fields[7].c_str())); galleryProfile.followed = fields[8] == "1"; galleryProfile.blocked = fields[9] == "1"; if (fields[10].size() < 400U * 1024U) galleryProfile.avatar = base64Decode(fields[10]); if (galleryProfileIsOwn) { galleryProfile.username = userName; galleryProfile.avatar = profileImageBytes; } galleryProfile.uploads.clear(); if (galleryProfilePage > 0) { bool foundUploads = false; while (std::getline(lines, line)) { if (!foundUploads) { if (line.rfind("UPLOADS\t", 0) == 0) foundUploads = true; continue; } if (!line.empty() && line.back() == '\r') line.pop_back(); if (line.empty()) continue; const auto noteFields = splitTabs(line); if (noteFields.size() < 12) continue; Item item{}; item.galleryId = noteFields[0]; item.title = percentDecode(noteFields[1]); item.description = percentDecode(noteFields[2]); item.author = percentDecode(noteFields[3]); item.fsid = noteFields[4]; item.rootId = noteFields[5]; item.currentId = noteFields[6]; item.modifiedUnixSeconds = static_cast(std::strtoull(noteFields[7].c_str(), nullptr, 10)); item.coins = std::max(0, std::atoi(noteFields[8].c_str())); item.views = std::max(0, std::atoi(noteFields[9].c_str())); item.allowSpinoffs = noteFields[10] == "1"; item.locked = noteFields.size() > 13 && noteFields[13] == "1"; if (noteFields[11].size() <= 1024U * 1024U) item.previewBytes = base64Decode(noteFields[11]); if (noteFields.size() > 12) item.ownerIdentity = noteFields[12]; galleryProfile.uploads.push_back(std::move(item)); } } gallery = galleryProfile.uploads; galleryTotal = galleryProfile.switchnotes; galleryStatus.clear(); gallerySelected = gallery.empty() ? 0 : std::min(gallerySelected, gallery.size() - 1); if (galleryProfileAvatarTexture) { SDL_DestroyTexture(galleryProfileAvatarTexture); galleryProfileAvatarTexture = nullptr; } if (!galleryProfile.avatar.empty() && galleryProfile.avatar.size() <= static_cast(std::numeric_limits::max())) { SDL_RWops* stream = SDL_RWFromConstMem(galleryProfile.avatar.data(), static_cast(galleryProfile.avatar.size())); SDL_Surface* surface = stream ? IMG_Load_RW(stream, 1) : nullptr; galleryProfileAvatarTexture = surface ? SDL_CreateTextureFromSurface(renderer, surface) : nullptr; if (surface) SDL_FreeSurface(surface); } if (galleryProfileIsOwn && galleryProfilePage == 0) saveGalleryProfile(true); } void App::saveGalleryProfile(bool quiet) { if (!galleryProfileIsOwn) return; std::string body = "username=" + percentEncode(userName) + "&bio=" + percentEncode(galleryProfile.bio) + "&avatar="; if (!profileImageBytes.empty()) body += percentEncode(base64Encode(profileImageBytes)); std::vector request(body.begin(), body.end()), response; int status = 0; std::string error; if (!galleryRequest("POST", "/api/profiles/update", request, "application/x-www-form-urlencoded", response, status, error) || status < 200 || status >= 300) { if (!quiet) showToast(error.empty() ? "Could not save Gallery profile." : error); return; } if (!quiet) showToast("Gallery profile saved"); } void App::updateGalleryRelationship(const char* action) { if (galleryProfileIsOwn || galleryProfile.identity.empty()) return; const std::string bodyText = "action=" + std::string(action) + "&target=" + galleryProfile.identity; std::vector body(bodyText.begin(), bodyText.end()), response; int status = 0; std::string error; if (!galleryRequest("POST", "/api/profiles/relationship", body, "application/x-www-form-urlencoded", response, status, error) || status < 200 || status >= 300) { if (error.empty()) error.assign(response.begin(), response.end()); showToast(error.empty() ? "Gallery profile action failed." : error); return; } if (std::string(action) == "follow") { galleryProfile.followed = true; ++galleryProfile.followers; } else if (std::string(action) == "unfollow") { galleryProfile.followed = false; galleryProfile.followers = std::max(0, galleryProfile.followers - 1); } else if (std::string(action) == "block") { galleryProfile.blocked = true; galleryProfile.followed = false; } else if (std::string(action) == "unblock") galleryProfile.blocked = false; } void App::finishGalleryRefresh(const std::vector& response, int status, const std::string& error) { (void)status; if (!error.empty()) { gallery.clear(); galleryTotal = 0; galleryStatus = error; showToast(error); return; } for (auto it = images.begin(); it != images.end();) { if (it->first.rfind("gallery-preview:", 0) == 0) { if (it->second) SDL_DestroyTexture(it->second); it = images.erase(it); } else ++it; } gallery.clear(); galleryTotal = 0; const std::string textBody(response.begin(), response.end()); std::istringstream lines(textBody); std::string line; bool firstLine = true; while (std::getline(lines, line)) { if (!line.empty() && line.back() == '\r') line.pop_back(); if (line.empty()) continue; if (firstLine) { firstLine = false; const auto header = splitTabs(line); if (header.size() > 1 && header[0] == "TOTAL") galleryTotal = std::max(0, std::atoi(header[1].c_str())); continue; } const auto fields = splitTabs(line); if (fields.size() < 12) continue; Item item{}; item.galleryId = fields[0]; item.title = percentDecode(fields[1]); item.description = percentDecode(fields[2]); item.author = percentDecode(fields[3]); item.fsid = fields[4]; item.rootId = fields[5]; item.currentId = fields[6]; item.modifiedUnixSeconds = static_cast(std::strtoull(fields[7].c_str(), nullptr, 10)); item.coins = std::max(0, std::atoi(fields[8].c_str())); item.views = std::max(0, std::atoi(fields[9].c_str())); item.allowSpinoffs = fields[10] == "1"; if (fields[11].size() <= 1024U * 1024U) item.previewBytes = base64Decode(fields[11]); if (fields.size() > 12) item.ownerIdentity = fields[12]; item.locked = fields.size() > 13 && fields[13] == "1"; gallery.push_back(std::move(item)); } gallerySelected = gallery.empty() ? 0 : std::min(gallerySelected, gallery.size() - 1); galleryCommentsLoaded = false; galleryStatus = gallery.empty() ? (galleryTotal > 0 ? "There are no Switchnotes on this page." : "No Switchnotes have been shared yet.") : std::string{}; if (!galleryTermsAccepted && screen == Screen::Gallery) { modal = Modal::GalleryTerms; modalTransitionStart = SDL_GetTicks(); } } void App::refreshGalleryComments() { galleryComments.clear(); for (auto& entry : galleryCommentTextures) if (entry.second) SDL_DestroyTexture(entry.second); galleryCommentTextures.clear(); if (gallery.empty()) { galleryCommentsLoaded = true; return; } const Item& item = gallery[gallerySelected % gallery.size()]; std::vector response; int status = 0; std::string error; if (!galleryRequest("GET", "/api/flipnotes/" + item.galleryId + "/comments", {}, "text/plain", response, status, error)) { showToast(error); galleryCommentsLoaded = false; return; } std::istringstream lines(std::string(response.begin(), response.end())); std::string line; bool firstLine = true; while (std::getline(lines, line)) { if (!line.empty() && line.back() == '\r') line.pop_back(); if (line.empty()) continue; if (firstLine) { firstLine = false; continue; } const auto fields = splitTabs(line); if (fields.size() < 4) continue; GalleryComment comment; comment.author = percentDecode(fields[0]); comment.text = percentDecode(fields[1]); const std::uint64_t created = static_cast(std::strtoull(fields[2].c_str(), nullptr, 10)); comment.created = dateText(created); if (fields[3].size() <= 450U * 1024U) comment.drawing = base64Decode(fields[3]); if (comment.drawing.size() != 0 && comment.drawing.size() != sppm::kPixelsPerLayer) comment.drawing.clear(); galleryComments.push_back(std::move(comment)); } galleryCommentsLoaded = true; } void App::openGalleryComments() { refreshGalleryComments(); galleryCommentScroll = 0; screen = Screen::GalleryComments; } SDL_Texture* App::galleryPreviewTexture(const Item& item) { if (item.previewBytes.empty() || item.previewBytes.size() > static_cast(std::numeric_limits::max())) return nullptr; const std::string key = "gallery-preview:" + item.galleryId; auto found = images.find(key); if (found != images.end()) return found->second; SDL_RWops* stream = SDL_RWFromConstMem(item.previewBytes.data(), static_cast(item.previewBytes.size())); SDL_Surface* surface = stream ? IMG_Load_RW(stream, 1) : nullptr; SDL_Texture* texture = surface ? SDL_CreateTextureFromSurface(renderer, surface) : nullptr; if (surface) SDL_FreeSurface(surface); if (texture) SDL_SetTextureScaleMode(texture, SDL_ScaleModeLinear); else logStartup("gallery preview decode failed: " + std::string(IMG_GetError())); images[key] = texture; return texture; } SDL_Texture* App::galleryCommentTexture(const GalleryComment& comment, std::size_t index) { if (comment.drawing.size() != sppm::kPixelsPerLayer) return nullptr; const std::string key = "gallery-comment:" + (gallery.empty() ? std::string{} : gallery[gallerySelected % gallery.size()].galleryId) + ":" + std::to_string(index); auto found = galleryCommentTextures.find(key); if (found != galleryCommentTextures.end()) return found->second; SDL_Surface* surface = SDL_CreateRGBSurfaceWithFormat(0, sppm::kWidth, sppm::kHeight, 32, SDL_PIXELFORMAT_RGBA32); if (!surface) return nullptr; auto* pixels = static_cast(surface->pixels); for (std::size_t i = 0; i < comment.drawing.size(); ++i) pixels[i] = comment.drawing[i] ? SDL_MapRGBA(surface->format, 20, 22, 28, 255) : SDL_MapRGBA(surface->format, 0, 0, 0, 0); SDL_Texture* texture = SDL_CreateTextureFromSurface(renderer, surface); SDL_FreeSurface(surface); if (texture) { SDL_SetTextureScaleMode(texture, SDL_ScaleModeNearest); SDL_SetTextureBlendMode(texture, SDL_BLENDMODE_BLEND); } galleryCommentTextures[key] = texture; return texture; } void App::publishGalleryItem(std::size_t index) { if (index >= library.size()) return; pendingGalleryPublishIndex = index; modalTransitionStart = SDL_GetTicks(); modal = Modal::GalleryPostChoice; } void App::performGalleryUpload(std::size_t index, bool locked) { if (index >= library.size()) return; const Item& item = library[index]; if (item.path.empty()) { showToast("This Switchnote cannot be sent to the Gallery"); return; } sppm::Document upload; std::string error; if (docLoaded && !galleryViewing && docPath == item.path) { if (dirty && !saveDocument(true)) return; upload = doc; } else if (!sppm::loadSignedFile(item.path, upload, signingIdentity.legacyHmacKey, error)) { showToast("Could not open Switchnote: " + error); return; } upload.metadata.lastEditorName = userName; upload.metadata.lastEditorFsid = myFsid; upload.metadata.lastEditorIdentity = signingIdentity.publicIdentity; upload.metadata.locked = locked; upload.metadata.modifiedUnixSeconds = nowSeconds(); const std::string tempId = newId("gallery-upload"); if (tempId.empty()) { showToast("Could not create a secure upload ID"); return; } const std::string tempPath = join(dataRoot, ".gallery-upload-" + tempId + ".sppm"); if (!sppm::savePublicSignedFile(tempPath, upload, signingIdentity, error)) { showToast("Could not sign Switchnote for Gallery: " + error); return; } std::ifstream input(tempPath, std::ios::binary | std::ios::ate); std::streamoff size = -1; if (input) size = static_cast(input.tellg()); if (size <= 0 || static_cast(size) > 64U * 1024U * 1024U) { input.close(); std::remove(tempPath.c_str()); showToast("Switchnote is too large to send to Gallery"); return; } std::vector bytes(static_cast(size)); input.seekg(0, std::ios::beg); if (!input) { input.close(); std::remove(tempPath.c_str()); showToast("Could not read signed Switchnote"); return; } input.read(reinterpret_cast(bytes.data()), static_cast(size)); const bool readOk = input.gcount() == static_cast(bytes.size()); input.close(); std::remove(tempPath.c_str()); if (!readOk) { showToast("Could not read signed Switchnote"); return; } std::vector response; int status = 0; if (!galleryRequest("POST", "/api/flipnotes", bytes, "application/octet-stream", response, status, error)) { showToast("Send failed: " + error); return; } showToast("Switchnote sent to Gallery"); enterGallery(); } void App::addGalleryCoin() { if (gallery.empty()) return; Item& item = gallery[gallerySelected % gallery.size()]; std::vector body, response; int status = 0; std::string error; if (!galleryRequest("POST", "/api/flipnotes/" + item.galleryId + "/coins", body, "application/x-www-form-urlencoded", response, status, error)) { showToast(error); return; } item.coins = std::max(0, std::atoi(std::string(response.begin(), response.end()).c_str())); showToast("Coin added"); } void App::deleteGalleryItem() { if (gallery.empty()) return; const std::string noteId = gallery[gallerySelected % gallery.size()].galleryId; std::vector response; int status = 0; std::string error; if (!galleryRequest("DELETE", "/api/flipnotes/" + noteId, {}, "application/octet-stream", response, status, error)) { showToast("Could not remove Switchnote: " + error); modal = Modal::None; return; } modal = Modal::None; screen = Screen::Gallery; if (galleryTotal > 0) --galleryTotal; if (galleryPage > 0 && static_cast(galleryPage * 6) >= static_cast(galleryTotal)) --galleryPage; galleryStatus = "Loading Gallery…"; refreshGallery(); showToast("Switchnote removed from Gallery"); } void App::postGalleryComment() { if (gallery.empty()) return; const bool hasDrawing = std::any_of(commentPixels.begin(), commentPixels.end(), [](std::uint8_t value) { return value != 0; }); if (commentDraft.empty() && !hasDrawing) { showToast("Write or draw a comment first"); return; } const Item& item = gallery[gallerySelected % gallery.size()]; std::string form = "author=" + percentEncode(userName) + "&text=" + percentEncode(commentDraft); if (hasDrawing) form += "&drawing=" + base64Encode(commentPixels); std::vector body(form.begin(), form.end()), response; int status = 0; std::string error; if (!galleryRequest("POST", "/api/flipnotes/" + item.galleryId + "/comments", body, "application/x-www-form-urlencoded", response, status, error)) { showToast("Comment failed: " + error); return; } commentDraft.clear(); commentPixels.assign(sppm::kPixelsPerLayer, 0); commentDirty = true; refreshGalleryComments(); screen = Screen::GalleryComments; showToast("Comment posted"); } void App::openGalleryItem(std::size_t index) { if (gallery.empty()) return; gallerySelected = index % gallery.size(); const Item& item = gallery[gallerySelected]; std::vector bytes; int status = 0; std::string error; if (!galleryRequest("GET", "/api/flipnotes/" + item.galleryId + "/file", {}, "application/octet-stream", bytes, status, error)) { showToast("Could not download Switchnote: " + error); return; } sppm::Document downloaded; if (!sppm::deserializeSigned(bytes, downloaded, signingIdentity.legacyHmacKey, error)) { showToast("Gallery Switchnote failed verification: " + error); return; } if (docLoaded && dirty && !galleryViewing && !saveDocument(true)) return; stopPlayback(); doc = std::move(downloaded); docLoaded = true; docPath.clear(); galleryViewing = true; dirty = false; frame = 0; zoom = 1.0f; panX = panY = 0; invalidateFrames(); rebuildAudio(); screen = Screen::Viewer; } void App::saveGalleryItem(std::size_t index) { LoadingScope loading(*this); if (index >= gallery.size()) return; const Item& item = gallery[index]; for (const Item& local : library) { if ((!item.currentId.empty() && local.currentId == item.currentId) || (!item.fsid.empty() && local.fsid == item.fsid)) { showToast("This Switchnote is already saved locally"); return; } } std::vector bytes, response; int status = 0; std::string error; if (!galleryRequest("GET", "/api/flipnotes/" + item.galleryId + "/file", {}, "application/octet-stream", bytes, status, error)) { showToast("Could not save Switchnote: " + error); return; } sppm::Document verified; if (!sppm::deserializeSigned(bytes, verified, signingIdentity.legacyHmacKey, error)) { showToast("Gallery Switchnote failed verification: " + error); return; } const std::string directory = join(notesRoot, folder); ensureDir(directory); const std::string saveId = newId("gallery-save"); if (saveId.empty()) { showToast("Could not create a secure local filename"); return; } const std::string destination = join(directory, saveId + ".sppm"); const std::string temporary = destination + ".saving"; std::ofstream output(temporary, std::ios::binary | std::ios::trunc); if (!output) { showToast("Could not save Switchnote to the SD card"); return; } output.write(reinterpret_cast(bytes.data()), static_cast(bytes.size())); output.flush(); const bool written = static_cast(output); output.close(); if (!written || std::rename(temporary.c_str(), destination.c_str()) != 0) { std::remove(temporary.c_str()); showToast("Could not save Switchnote to the SD card"); return; } scanLibrary(); showToast(verified.metadata.locked ? "Locked Switchnote saved locally (read-only)" : "Switchnote saved locally"); } void App::poll() { pointerPressed = false; pointerReleased = false; touchHoverExpiryPending = false; clickConsumed = false; buttonsDown = 0; buttonsUp = 0; wheel = 0; const auto canvasControlAt = [&](int x, int y) { if (screen != Screen::Canvas || canvasChromeVisible < 0.999f) return false; if (paintMenu || colorMenu || toolMenu) return true; const int menuX = 154; const int menuY = 635 + static_cast(std::lround((1.0f - canvasMenuProgress) * 95.0f)); return (x >= 20 && x < 164 && y >= 18 && y < 72) || (x >= 20 && x < 92 && y >= 238 && y < 498) || (x >= 1200 && x < 1260 && y >= 12 && y < 212) || (x >= 20 && x < 164 && y >= 635 && y < 697) || (canvasMenuProgress > 0.0f && x >= menuX && x < menuX + 310 && y >= menuY - 9 && y < menuY + 71) || (x >= 474 && x < 800 && y >= 635 && y < 700) || (x >= 904 && x < 1230 && y >= 635 && y < 700); }; const auto closeCanvasMenuOnOutsidePress = [&](int x, int y) { if (screen != Screen::Canvas || !canvasMenuOpen) return; const int menuX = 154; const int menuY = 635 + static_cast(std::lround((1.0f - canvasMenuProgress) * 95.0f)); const bool onToggle = x >= 92 && x < 156 && y >= 635 && y < 697; const bool onPopup = canvasMenuProgress > 0.0f && x >= menuX && x < menuX + 310 && y >= menuY - 9 && y < menuY + 71; if (!onToggle && !onPopup) canvasMenuOpen = false; }; SDL_Event event; while (SDL_PollEvent(&event)) { switch (event.type) { case SDL_QUIT: running = false; break; case SDL_CONTROLLERDEVICEADDED: if (!controller && SDL_IsGameController(event.cdevice.which)) controller = SDL_GameControllerOpen(event.cdevice.which); break; case SDL_CONTROLLERDEVICEREMOVED: if (controller && SDL_JoystickInstanceID(SDL_GameControllerGetJoystick(controller)) == event.cdevice.which) { SDL_GameControllerClose(controller); controller = nullptr; } break; case SDL_CONTROLLERBUTTONDOWN: { const auto bit = controllerBit(event.cbutton.button); buttonsDown |= bit; buttonsHeld |= bit; break; } case SDL_CONTROLLERBUTTONUP: { const auto bit = controllerBit(event.cbutton.button); buttonsHeld &= ~bit; buttonsUp |= bit; break; } case SDL_KEYDOWN: { if (event.key.repeat) break; if (modal == Modal::TextInput) { if (event.key.keysym.sym == SDLK_BACKSPACE && !inputText.empty()) inputText.pop_back(); else if (event.key.keysym.sym == SDLK_RETURN) applyText(); else if (event.key.keysym.sym == SDLK_ESCAPE) { modal = Modal::None; SDL_StopTextInput(); } break; } std::uint32_t bit = 0; switch (event.key.keysym.sym) { case SDLK_RETURN: case SDLK_SPACE: case SDLK_a: bit = BA; break; case SDLK_ESCAPE: case SDLK_b: bit = BB; break; case SDLK_x: bit = BX; break; case SDLK_y: bit = BY; break; case SDLK_l: bit = BL; break; case SDLK_r: bit = BR; break; case SDLK_q: bit = BZL; break; case SDLK_e: bit = BZR; break; case SDLK_LEFT: bit = BLEFT; break; case SDLK_RIGHT: bit = BRIGHT; break; case SDLK_UP: bit = BUP; break; case SDLK_DOWN: bit = BDOWN; break; case SDLK_EQUALS: bit = BPLUS; break; case SDLK_MINUS: bit = BMINUS; break; default: break; } buttonsDown |= bit; buttonsHeld |= bit; break; } case SDL_KEYUP: { std::uint32_t bit = 0; switch (event.key.keysym.sym) { case SDLK_RETURN: case SDLK_SPACE: case SDLK_a: bit = BA; break; case SDLK_ESCAPE: case SDLK_b: bit = BB; break; case SDLK_x: bit = BX; break; case SDLK_y: bit = BY; break; case SDLK_l: bit = BL; break; case SDLK_r: bit = BR; break; case SDLK_q: bit = BZL; break; case SDLK_e: bit = BZR; break; case SDLK_LEFT: bit = BLEFT; break; case SDLK_RIGHT: bit = BRIGHT; break; case SDLK_UP: bit = BUP; break; case SDLK_DOWN: bit = BDOWN; break; case SDLK_EQUALS: bit = BPLUS; break; case SDLK_MINUS: bit = BMINUS; break; default: break; } buttonsHeld &= ~bit; buttonsUp |= bit; break; } case SDL_TEXTINPUT: if (modal == Modal::TextInput && inputText.size() < 240) inputText += event.text.text; break; case SDL_MOUSEMOTION: if (event.motion.which != SDL_TOUCH_MOUSEID) { pointerX = event.motion.x; pointerY = event.motion.y; } break; case SDL_MOUSEBUTTONDOWN: if (event.button.which != SDL_TOUCH_MOUSEID && event.button.button == SDL_BUTTON_LEFT) { pointerX = event.button.x; pointerY = event.button.y; pointerDown = pointerPressed = true; hasLastStroke = false; closeCanvasMenuOnOutsidePress(pointerX, pointerY); canvasControlGesture = canvasControlAt(pointerX, pointerY); const bool inkSurface = (screen == Screen::Canvas && !canvasControlGesture) || (screen == Screen::CommentCanvas && pointerY >= 72 && pointerY < 620); if (!inkSurface) playEvent("ui_button_down", "none"); } break; case SDL_MOUSEBUTTONUP: if (event.button.which != SDL_TOUCH_MOUSEID && event.button.button == SDL_BUTTON_LEFT) { pointerX = event.button.x; pointerY = event.button.y; pointerDown = false; pointerReleased = true; const bool inkSurface = (screen == Screen::Canvas && !canvasControlGesture) || (screen == Screen::CommentCanvas && pointerY >= 72 && pointerY < 620); if (!inkSurface) playEvent("ui_button_up", "none"); } break; case SDL_FINGERDOWN: pointerX = static_cast(event.tfinger.x * W); pointerY = static_cast(event.tfinger.y * H); pointerDown = pointerPressed = true; hasLastStroke = false; closeCanvasMenuOnOutsidePress(pointerX, pointerY); canvasControlGesture = canvasControlAt(pointerX, pointerY); if (!(screen == Screen::Canvas && !canvasControlGesture) && !(screen == Screen::CommentCanvas && pointerY >= 72 && pointerY < 620)) playEvent("ui_button_down", "none"); break; case SDL_FINGERMOTION: pointerX = static_cast(event.tfinger.x * W); pointerY = static_cast(event.tfinger.y * H); break; case SDL_FINGERUP: pointerX = static_cast(event.tfinger.x * W); pointerY = static_cast(event.tfinger.y * H); pointerDown = false; pointerReleased = true; touchHoverExpiryPending = true; if (!(screen == Screen::Canvas && !canvasControlGesture) && !(screen == Screen::CommentCanvas && pointerY >= 72 && pointerY < 620)) playEvent("ui_button_up", "none"); break; case SDL_MOUSEWHEEL: wheel += event.wheel.y; break; default: break; } } if (controller) { const std::uint32_t previousHeld = buttonsHeld; const bool zl = SDL_GameControllerGetAxis(controller, SDL_CONTROLLER_AXIS_TRIGGERLEFT) > 16000; const bool zr = SDL_GameControllerGetAxis(controller, SDL_CONTROLLER_AXIS_TRIGGERRIGHT) > 16000; if (zl && !(buttonsHeld & BZL)) buttonsDown |= BZL; if (zr && !(buttonsHeld & BZR)) buttonsDown |= BZR; if (!zl && (previousHeld & BZL)) buttonsUp |= BZL; if (!zr && (previousHeld & BZR)) buttonsUp |= BZR; buttonsHeld = (buttonsHeld & ~(BZL | BZR)) | (zl ? static_cast(BZL) : 0u) | (zr ? static_cast(BZR) : 0u); const auto normalizedStick = [](Sint16 value) { constexpr float deadZone = 0.18f; const float normalized = value < 0 ? value / 32768.0f : value / 32767.0f; if (std::abs(normalized) <= deadZone) return 0.0f; return (normalized - (normalized < 0.0f ? -deadZone : deadZone)) / (1.0f - deadZone); }; leftStickX = normalizedStick(SDL_GameControllerGetAxis(controller, SDL_CONTROLLER_AXIS_LEFTX)); leftStickY = normalizedStick(SDL_GameControllerGetAxis(controller, SDL_CONTROLLER_AXIS_LEFTY)); } else { leftStickX = leftStickY = 0.0f; } const std::array, 17> inputButtons{{ {BA, "a"}, {BB, "b"}, {BX, "x"}, {BY, "y"}, {BL, "l"}, {BR, "r"}, {BZL, "zl"}, {BZR, "zr"}, {BLEFT, "left"}, {BRIGHT, "right"}, {BUP, "up"}, {BDOWN, "down"}, {BPLUS, "plus"}, {BMINUS, "minus"}, {BLS, "ls"}, {BRS, "rs"}, {BMISC, "misc"} }}; for (const auto& button : inputButtons) { if (buttonsDown & button.first) playEvent(std::string("button_") + button.second + "_down", "none"); if (buttonsUp & button.first) playEvent(std::string("button_") + button.second + "_up", "none"); } } void App::showToast(const std::string& message) { toast = message; toastUntil = SDL_GetTicks() + 2500; } void App::loadPreferences() { std::ifstream in(join(dataRoot, "preferences.ini")); int storedFps = defaultFps; int storedOnion = onion ? 1 : 0; int storedGrid = canvasGrid ? 1 : 0; int storedCopy = copyToNewPage ? 1 : 0; int storedStabilisation = penStabilisationEnabled ? 1 : 0; int storedAfter = penStabilisationAfter ? 1 : 0; int storedStrength = penStabilisationStrength; if (in >> storedFps >> storedOnion >> storedGrid) { defaultFps = std::clamp(storedFps, 1, 30); defaultSpeed = speedFromFps(defaultFps); onion = storedOnion != 0; canvasGrid = storedGrid != 0; if (in >> storedCopy) { copyToNewPage = storedCopy != 0; if (in >> storedStabilisation >> storedAfter >> storedStrength) { penStabilisationEnabled = storedStabilisation != 0; penStabilisationAfter = storedAfter != 0; penStabilisationStrength = std::clamp(storedStrength, 0, 100); } } int storedSpeed = 0; if (in >> storedSpeed) { if (storedSpeed >= 1 && storedSpeed <= 8) defaultSpeed = storedSpeed; } } std::ifstream branding(join(dataRoot, "branding.ini"), std::ios::binary); std::string line; if (branding && std::getline(branding, line) && !line.empty() && line.size() <= 40) appDisplayName = line; if (branding && std::getline(branding, line) && !line.empty() && line.size() <= 24) noteSingular = line; if (branding && std::getline(branding, line) && !line.empty() && line.size() <= 24) notePlural = line; const std::string savedLogo = join(dataRoot, "branding-logo.png"); std::ifstream logo(savedLogo, std::ios::binary); if (logo) brandingLogoPath = savedLogo; std::ifstream galleryTerms(join(dataRoot, "gallery-terms-accepted"), std::ios::binary); galleryTermsAccepted = static_cast(galleryTerms); } bool App::startBrandingConfigServer() { #ifdef __SWITCH__ if (brandingConfigServer.isRunning()) return true; if (!gallerySocketsReady) { const Result sockets = socketInitializeDefault(); if (R_FAILED(sockets)) { brandingStatus = "Network initialization failed."; return false; } gallerySocketsReady = true; } if (!nifmReady) { const Result network = nifmInitialize(NifmServiceType_User); if (R_FAILED(network)) { brandingStatus = "Could not read the Switch network address."; return false; } nifmReady = true; } u32 rawAddress = 0; if (R_FAILED(nifmGetCurrentIpAddress(&rawAddress))) { brandingStatus = "Connect the Switch to local Wi-Fi first."; return false; } const std::uint32_t hostAddress = ntohl(rawAddress); const bool privateAddress = (hostAddress >> 24) == 10 || (hostAddress & 0xfff00000U) == 0xac100000U || (hostAddress & 0xffff0000U) == 0xc0a80000U || (hostAddress & 0xffff0000U) == 0xa9fe0000U || (hostAddress & 0xffc00000U) == 0x64400000U; if (!privateAddress) { brandingStatus = "Connect to a private local network to use the portal."; return false; } in_addr address{}; address.s_addr = rawAddress; char addressText[INET_ADDRSTRLEN]{}; if (!::inet_ntop(AF_INET, &address, addressText, sizeof(addressText))) { brandingStatus = "Could not format the Switch address."; return false; } std::uint32_t randomValue = 0; do { if (!randomBytes(&randomValue, sizeof(randomValue))) { brandingStatus = "Could not create a secure connection code."; return false; } } while (randomValue >= 4294000000U); const unsigned int pin = randomValue % 1000000U; char formattedCode[8]{}; std::snprintf(formattedCode, sizeof(formattedCode), "%03u-%03u", pin / 1000U, pin % 1000U); std::string error; if (!brandingConfigServer.start(addressText, dataRoot, assetsRoot, formattedCode, error, appDisplayName, noteSingular, notePlural)) { brandingStatus = error.empty() ? "Could not start the branding portal." : error; return false; } brandingCode = formattedCode; brandingUrl = "http://" + std::string(addressText) + ":8767"; brandingStatus = "Enter the code on your PC."; return true; #else brandingStatus = "The branding portal is available in the Switch homebrew app."; return false; #endif } void App::stopBrandingConfigServer() { brandingConfigServer.stop(); brandingUrl.clear(); brandingCode.clear(); } void App::closeBrandingMenu() { stopBrandingConfigServer(); modal = Modal::None; } void App::consumeBrandingConfig() { if (!brandingConfigServer.takeConfigUpdate()) return; const std::string configPath = join(dataRoot, "branding.ini"); std::ifstream in(configPath, std::ios::binary); std::string app, singular, plural; if (!in || !std::getline(in, app) || !std::getline(in, singular) || !std::getline(in, plural)) { brandingStatus = "Branding file could not be loaded."; return; } if (app.empty() || app.size() > 40 || singular.empty() || singular.size() > 24 || plural.empty() || plural.size() > 24) { brandingStatus = "Branding values are invalid."; return; } const std::string pending = join(dataRoot, "branding-logo.pending"); std::ifstream logo(pending, std::ios::binary); if (logo) { logo.close(); SDL_Texture* validationTexture = loadBrandingLogo(pending); if (!validationTexture) { brandingStatus = "The uploaded logo could not be decoded."; showToast(brandingStatus); return; } SDL_DestroyTexture(validationTexture); const std::string destination = join(dataRoot, "branding-logo.png"); // FAT/SD rename replacement is unreliable on Switch. The pending // upload has already been decoded successfully, so copy it into the // stable logo path and verify the completed file before accepting it. std::ifstream uploaded(pending, std::ios::binary | std::ios::ate); if (!uploaded) { brandingStatus = "The uploaded logo could not be opened."; showToast(brandingStatus); return; } const std::streamoff logoSize = uploaded.tellg(); if (logoSize <= 0 || logoSize > 8 * 1024 * 1024) { brandingStatus = "The uploaded logo has an invalid size."; showToast(brandingStatus); return; } std::vector logoBytes(static_cast(logoSize)); uploaded.seekg(0, std::ios::beg); uploaded.read(logoBytes.data(), static_cast(logoBytes.size())); if (!uploaded) { brandingStatus = "The uploaded logo could not be read."; showToast(brandingStatus); return; } uploaded.close(); std::ofstream savedLogo(destination, std::ios::binary | std::ios::trunc); if (!savedLogo) { brandingStatus = "The title logo could not be opened on the SD card."; showToast(brandingStatus); return; } savedLogo.write(logoBytes.data(), static_cast(logoBytes.size())); savedLogo.flush(); if (!savedLogo) { savedLogo.close(); brandingStatus = "The title logo could not be written to the SD card."; showToast(brandingStatus); return; } savedLogo.close(); SDL_Texture* savedValidation = savedLogo ? loadBrandingLogo(destination) : nullptr; if (savedValidation) SDL_DestroyTexture(savedValidation); if (!savedLogo || !savedValidation) { brandingStatus = "The title logo could not be verified after saving."; showToast(brandingStatus); return; } std::remove(pending.c_str()); brandingLogoPath = destination; } bool hasLoadingUpload = false; for (std::size_t i = 0; i < loadingFrogFrames.size(); ++i) { const std::string pendingFrame = join(dataRoot, "branding-loading" + std::to_string(i) + ".pending"); std::ifstream pending(pendingFrame, std::ios::binary | std::ios::ate); if (!pending) continue; hasLoadingUpload = true; const std::streamoff length = pending.tellg(); if (length <= 8 || length > 128 * 1024) { brandingStatus = "A loading frame has an invalid size."; showToast(brandingStatus); return; } pending.close(); SDL_Texture* check = loadBrandingLogo(pendingFrame); if (!check) { brandingStatus = "A loading frame could not be decoded."; showToast(brandingStatus); return; } int frameW = 0, frameH = 0; SDL_QueryTexture(check, nullptr, nullptr, &frameW, &frameH); SDL_DestroyTexture(check); if (frameW != 64 || frameH != 64) { brandingStatus = "Loading frames must be 64 by 64 pixels."; showToast(brandingStatus); return; } } if (hasLoadingUpload) { for (std::size_t i = 0; i < loadingFrogFrames.size(); ++i) { const std::string pendingFrame = join(dataRoot, "branding-loading" + std::to_string(i) + ".pending"); std::ifstream inFrame(pendingFrame, std::ios::binary | std::ios::ate); if (!inFrame) { brandingStatus = "The loading animation upload is incomplete."; showToast(brandingStatus); return; } const std::streamoff length = inFrame.tellg(); std::vector frameBytes(static_cast(length)); inFrame.seekg(0); inFrame.read(frameBytes.data(), length); if (!inFrame) { brandingStatus = "Could not read the loading animation."; showToast(brandingStatus); return; } const std::string destination = join(dataRoot, "branding-loading" + std::to_string(i) + ".png"); std::ofstream outFrame(destination, std::ios::binary | std::ios::trunc); if (!outFrame) { brandingStatus = "Could not open a loading frame on the SD card."; showToast(brandingStatus); return; } outFrame.write(frameBytes.data(), static_cast(frameBytes.size())); outFrame.close(); SDL_Texture* replacement = loadBrandingLogo(destination); if (!outFrame || !replacement) { if (replacement) SDL_DestroyTexture(replacement); brandingStatus = "Could not save the loading animation."; showToast(brandingStatus); return; } if (loadingFrogFrames[i]) SDL_DestroyTexture(loadingFrogFrames[i]); loadingFrogFrames[i] = replacement; std::remove(pendingFrame.c_str()); } } brandingStatus = "Branding saved. Restart the app for changes to apply."; if (modal == Modal::Branding) showToast(brandingStatus); } void App::exportBrandingConfig() { branding::Bundle bundle; bundle.app = appDisplayName; bundle.singular = noteSingular; bundle.plural = notePlural; const std::string logoPath = brandingLogoPath.empty() ? join(assetsRoot, "art/switchnotedefault.png") : brandingLogoPath; std::ifstream logo(logoPath, std::ios::binary | std::ios::ate); if (!logo) { showToast("Could not read the current title logo"); return; } const std::streamoff size = logo.tellg(); if (size <= 0 || size > 8 * 1024 * 1024) { showToast("The current title logo has an invalid size"); return; } bundle.logo.resize(static_cast(size)); logo.seekg(0); logo.read(reinterpret_cast(bundle.logo.data()), size); if (!logo) { showToast("Could not read the current title logo"); return; } const std::string directory = join(dataRoot, "exports"); mkdir(directory.c_str(), 0777); std::string error; if (!branding::writeConfig(join(directory, "branding.config"), bundle, error)) { showToast(error); return; } showToast("Branding exported to exports/branding.config"); } void App::importBrandingConfig() { branding::Bundle bundle; std::string error; if (!branding::readConfig(join(dataRoot, "branding.config"), bundle, error)) { showToast(error); return; } const std::string staging = join(dataRoot, "branding-logo.import"); { std::ofstream image(staging, std::ios::binary | std::ios::trunc); if (!image) { showToast("Could not stage the imported logo"); return; } image.write(reinterpret_cast(bundle.logo.data()), static_cast(bundle.logo.size())); image.flush(); if (!image) { image.close(); std::remove(staging.c_str()); showToast("Could not write the imported logo"); return; } } SDL_Texture* validation = loadBrandingLogo(staging); if (!validation) { std::remove(staging.c_str()); showToast("The imported logo is not a valid image"); return; } SDL_DestroyTexture(validation); const std::string configPath = join(dataRoot, "branding.ini"); std::ofstream config(configPath, std::ios::binary | std::ios::trunc); if (!config) { std::remove(staging.c_str()); showToast("Could not save imported branding"); return; } config << bundle.app << '\n' << bundle.singular << '\n' << bundle.plural << '\n'; config.flush(); if (!config) { config.close(); std::remove(staging.c_str()); showToast("Could not save imported branding"); return; } config.close(); const std::string logoPath = join(dataRoot, "branding-logo.png"); std::ifstream src(staging, std::ios::binary); std::ofstream dst(logoPath, std::ios::binary | std::ios::trunc); dst << src.rdbuf(); dst.flush(); const bool logoSaved = static_cast(src) && static_cast(dst); dst.close(); src.close(); std::remove(staging.c_str()); if (!logoSaved) { showToast("Branding was loaded, but the title logo could not be saved"); return; } brandingStatus = "Branding imported. Restart the app for changes to apply."; showToast(brandingStatus); } void App::savePreferences() { std::ofstream out(join(dataRoot, "preferences.ini"), std::ios::trunc); if (out) { out << defaultFps << ' ' << (onion ? 1 : 0) << ' ' << (canvasGrid ? 1 : 0) << ' ' << (copyToNewPage ? 1 : 0) << ' ' << (penStabilisationEnabled ? 1 : 0) << ' ' << (penStabilisationAfter ? 1 : 0) << ' ' << penStabilisationStrength << ' ' << defaultSpeed << '\n'; } } void App::playCue(const std::string& name) { playEvent("ui_action", name); } void App::playEvent(const std::string& event, const std::string& defaultCue) { std::string cue = defaultCue; if (event == "ui_button_down" || (event.rfind("button_", 0) == 0 && endsWith(event, "_down"))) cue = "button_on"; else if (event == "ui_button_up" || (event.rfind("button_", 0) == 0 && endsWith(event, "_up"))) cue = "button_off"; else if (event == "frame_previous") cue = "page_backward"; else if (event == "frame_next") cue = "page_forward"; else if (event == "frame_first") cue = "page_first"; else if (event == "frame_last") cue = "page_end"; else if (event == "frame_copy") cue = "edit_page_copy"; else if (event == "frame_paste") cue = "edit_page_paste"; else if (event == "frame_erase") cue = "edit_page_remove"; else if (event == "tool_color") cue = "popup_select"; if (cue.empty() || cue == "none") return; if (!audioReady) return; auto found = cues.find(cue); if (found == cues.end() || !found->second) return; const int channel = cue == "button_on" ? 50 : cue == "button_off" ? 51 : 15; Mix_HaltChannel(channel); Mix_PlayChannel(channel, found->second, 0); } SDL_Texture* App::image(const std::string& relative) { auto found = images.find(relative); if (found != images.end()) return found->second; const std::string path = join(assetsRoot, relative); SDL_Texture* tex = nullptr; if (endsWith(relative, ".svg")) { SDL_RWops* source = SDL_RWFromFile(path.c_str(), "rb"); SDL_Surface* surface = source ? IMG_LoadSVG_RW(source) : nullptr; if (source) SDL_RWclose(source); if (surface) { tex = SDL_CreateTextureFromSurface(renderer, surface); SDL_FreeSurface(surface); } } else tex = IMG_LoadTexture(renderer, path.c_str()); if (tex && relative.find("art/") == 0) SDL_SetTextureScaleMode(tex, SDL_ScaleModeNearest); if (!tex) logStartup("asset load failed: " + path + " :: " + IMG_GetError()); images[relative] = tex; return tex; } SDL_Texture* App::whiteImage(const std::string& relative) { auto found = whiteImages.find(relative); if (found != whiteImages.end()) return found->second; const std::string path = join(assetsRoot, relative); SDL_Surface* source = nullptr; if (endsWith(relative, ".svg")) { SDL_RWops* stream = SDL_RWFromFile(path.c_str(), "rb"); source = stream ? IMG_LoadSVG_RW(stream) : nullptr; if (stream) SDL_RWclose(stream); } else source = IMG_Load(path.c_str()); if (!source) { logStartup("white icon load failed: " + path + " :: " + IMG_GetError()); whiteImages[relative] = nullptr; return nullptr; } SDL_Surface* rgba = SDL_ConvertSurfaceFormat(source, SDL_PIXELFORMAT_RGBA32, 0); SDL_FreeSurface(source); if (!rgba) { whiteImages[relative] = nullptr; return nullptr; } if (SDL_MUSTLOCK(rgba)) SDL_LockSurface(rgba); for (int y = 0; y < rgba->h; ++y) { auto* row = reinterpret_cast(static_cast(rgba->pixels) + y * rgba->pitch); for (int x = 0; x < rgba->w; ++x) { Uint8 r, g, b, a; SDL_GetRGBA(row[x], rgba->format, &r, &g, &b, &a); row[x] = SDL_MapRGBA(rgba->format, 255, 255, 255, a); } } if (SDL_MUSTLOCK(rgba)) SDL_UnlockSurface(rgba); SDL_Texture* texture = SDL_CreateTextureFromSurface(renderer, rgba); SDL_FreeSurface(rgba); if (texture) SDL_SetTextureBlendMode(texture, SDL_BLENDMODE_BLEND); whiteImages[relative] = texture; return texture; } SDL_Texture* App::loadBrandingLogo(const std::string& path) { std::ifstream file(path, std::ios::binary | std::ios::ate); if (!file) return nullptr; const std::streamoff length = file.tellg(); if (length < 8 || length > 8 * 1024 * 1024 || length > std::numeric_limits::max()) return nullptr; std::vector bytes(static_cast(length)); file.seekg(0, std::ios::beg); if (!file.read(reinterpret_cast(bytes.data()), length)) return nullptr; file.close(); SDL_RWops* stream = SDL_RWFromConstMem(bytes.data(), static_cast(bytes.size())); SDL_Surface* surface = stream ? IMG_Load_RW(stream, 1) : nullptr; if (!surface) return nullptr; if (surface->w <= 0 || surface->h <= 0 || surface->w > 8192 || surface->h > 8192) { SDL_FreeSurface(surface); return nullptr; } SDL_Texture* texture = SDL_CreateTextureFromSurface(renderer, surface); SDL_FreeSurface(surface); if (texture) SDL_SetTextureBlendMode(texture, SDL_BLENDMODE_BLEND); return texture; } void App::drawTexture(SDL_Texture* texture, int x, int y, int w, int h) { if (!texture || w <= 0 || h <= 0) return; SDL_Rect dest{x, y, w, h}; SDL_RenderCopy(renderer, texture, nullptr, &dest); } void App::drawImage(const std::string& relative, int x, int y, int w, int h, bool nearest) { SDL_Texture* tex = image(relative); if (tex && nearest) SDL_SetTextureScaleMode(tex, SDL_ScaleModeNearest); drawTexture(tex, x, y, w, h); } TTF_Font* App::font(int px, bool bold) { (void)bold; const auto key = std::make_pair(px, false); auto found = fonts.find(key); if (found != fonts.end()) { return found->second; } const std::string regularPath = join(assetsRoot, "fonts/FOT-Seurat.otf"); TTF_Font* f = TTF_OpenFont(regularPath.c_str(), px); fonts[key] = f; return f; } std::string App::brandString(const std::string& label) const { if (label == appDisplayName) return label; std::string display = label; auto replaceTerm = [&](const std::string& oldTerm, const std::string& newTerm) { std::size_t at = 0; while (!oldTerm.empty() && (at = display.find(oldTerm, at)) != std::string::npos) { const bool left = at == 0 || !std::isalnum(static_cast(display[at - 1])); const std::size_t end = at + oldTerm.size(); const bool right = end == display.size() || !std::isalnum(static_cast(display[end])); if (left && right) { display.replace(at, oldTerm.size(), newTerm); at += newTerm.size(); } else at = end; } }; replaceTerm("Switchnote Studio", appDisplayName); replaceTerm("switchnote studio", appDisplayName); replaceTerm("Switchnotes", notePlural); replaceTerm("Switchnote", noteSingular); replaceTerm("switchnotes", notePlural); replaceTerm("switchnote", noteSingular); return display; } void App::text(const std::string& label, int x, int y, int px, SDL_Color color, bool bold) { rawText(brandString(label), x, y, px, color, bold); } void App::rawText(const std::string& display, int x, int y, int px, SDL_Color color, bool bold) { if (display.empty()) return; TTF_Font* f = font(px, bold); if (!f) return; const std::string key = std::to_string(px) + (bold ? "B" : "R") + std::to_string(color.r) + ":" + std::to_string(color.g) + ":" + std::to_string(color.b) + ":" + display; SDL_Texture* tex = nullptr; auto found = textCache.find(key); if (found != textCache.end()) tex = found->second; else { SDL_Surface* surf = TTF_RenderUTF8_Blended(f, display.c_str(), color); if (!surf) return; tex = SDL_CreateTextureFromSurface(renderer, surf); SDL_FreeSurface(surf); if (!tex) return; textCache[key] = tex; if (textCache.size() > 400) { for (auto& old : textCache) if (old.second != tex) SDL_DestroyTexture(old.second); textCache.clear(); textCache[key] = tex; } } int w = 0, h = 0; SDL_QueryTexture(tex, nullptr, nullptr, &w, &h); drawTexture(tex, x - 3, y + 1, w, h); } void App::centered(const std::string& label, int x, int y, int w, int h, int px, SDL_Color color, bool bold) { const std::string display = brandString(label); TTF_Font* f = font(px, bold); if (!f) return; int tw = 0, th = 0; TTF_SizeUTF8(f, display.c_str(), &tw, &th); rawText(display, x + (w - tw) / 2, y + (h - th) / 2, px, color, bold); } void App::fill(int x, int y, int w, int h, SDL_Color c) { SDL_SetRenderDrawColor(renderer, c.r, c.g, c.b, c.a); SDL_Rect rect{x, y, w, h}; SDL_RenderFillRect(renderer, &rect); } void App::drawPlayTriangle(int x, int y, int size, SDL_Color color) { if (!renderer || size <= 1) return; SDL_SetRenderDrawColor(renderer, color.r, color.g, color.b, color.a); for (int row = 0; row < size; ++row) { const int extent = std::min(row, size - 1 - row); SDL_RenderDrawLine(renderer, x, y + row, x + extent * 2, y + row); } } void App::drawLockIcon(int x, int y) { rounded(x, y, 28, 28, 14, SDL_Color{20, 22, 28, 220}); SDL_SetRenderDrawColor(renderer, 255, 255, 255, 255); SDL_RenderDrawLine(renderer, x + 8, y + 13, x + 8, y + 10); SDL_RenderDrawLine(renderer, x + 8, y + 10, x + 10, y + 7); SDL_RenderDrawLine(renderer, x + 10, y + 7, x + 18, y + 7); SDL_RenderDrawLine(renderer, x + 18, y + 7, x + 20, y + 10); SDL_RenderDrawLine(renderer, x + 20, y + 10, x + 20, y + 13); rounded(x + 6, y + 12, 16, 12, 3, WHITE); rounded(x + 13, y + 16, 2, 5, 1, INK); } void App::rounded(int x, int y, int w, int h, int radius, SDL_Color c) { if (w <= 0 || h <= 0) return; radius = std::max(0, std::min(radius, std::min(w, h) / 2)); SDL_SetRenderDrawColor(renderer, c.r, c.g, c.b, c.a); for (int row = 0; row < h; ++row) { int inset = 0; if (row < radius) { const float dy = static_cast(radius - row - 1); inset = radius - static_cast(std::sqrt(static_cast(radius * radius) - dy * dy)); } else if (row >= h - radius) { const float dy = static_cast(row - (h - radius)); inset = radius - static_cast(std::sqrt(static_cast(radius * radius) - dy * dy)); } SDL_RenderDrawLine(renderer, x + inset, y + row, x + w - inset - 1, y + row); } } void App::roundedOutline(int x, int y, int w, int h, int radius, int thickness, SDL_Color c) { if (w <= 0 || h <= 0 || thickness <= 0) return; radius = std::clamp(radius, 0, std::min(w, h) / 2); thickness = std::min(thickness, std::min(w, h) / 2); const int innerW = w - thickness * 2; const int innerH = h - thickness * 2; const int innerRadius = std::max(0, radius - thickness); SDL_SetRenderDrawColor(renderer, c.r, c.g, c.b, c.a); auto insetFor = [](int row, int height, int r) { if (r <= 0) return 0; if (row < r) { const float dy = static_cast(r - row - 1); return r - static_cast(std::sqrt(static_cast(r * r) - dy * dy)); } if (row >= height - r) { const float dy = static_cast(row - (height - r)); return r - static_cast(std::sqrt(static_cast(r * r) - dy * dy)); } return 0; }; for (int row = 0; row < h; ++row) { const int outerInset = insetFor(row, h, radius); const int outerLeft = x + outerInset; const int outerRight = x + w - outerInset - 1; const int innerRow = row - thickness; if (innerRow < 0 || innerRow >= innerH) { SDL_RenderDrawLine(renderer, outerLeft, y + row, outerRight, y + row); continue; } const int innerInset = insetFor(innerRow, innerH, innerRadius); const int innerLeft = x + thickness + innerInset; const int innerRight = x + thickness + innerW - innerInset - 1; if (outerLeft < innerLeft) SDL_RenderDrawLine(renderer, outerLeft, y + row, innerLeft - 1, y + row); if (innerRight < outerRight) SDL_RenderDrawLine(renderer, innerRight + 1, y + row, outerRight, y + row); } } bool App::hit(int x, int y, int w, int h) { return !updateInProgress && !modalClosing && !screen.transitioning() && !(screen == Screen::Canvas && canvasChromeVisible < 0.999f) && (modal == Modal::None || renderingModal) && pointerReleased && !clickConsumed && pointerX >= x && pointerX < x + w && pointerY >= y && pointerY < y + h; } int App::hoverLift(int x, int y, bool hovered) { const auto key = (static_cast(static_cast(x)) << 32) | static_cast(y); float& amount = hoverMotion[key]; if (touchHoverExpiryPending && !pointerDown) { amount = 0.0f; return 0; } amount += ((hovered ? 1.0f : 0.0f) - amount) * 0.62f; return static_cast(std::lround(amount * 3.0f)); } bool App::pill(const std::string& label, int x, int y, int w, int h, bool green, int px, bool enabled, const std::string& cue) { const bool hovered = pointerX >= x && pointerX < x + w && pointerY >= y && pointerY < y + h; const bool pressed = enabled && pointerDown && hovered; const int lift = enabled ? hoverLift(x, y, hovered) : 0; const int press = pressed ? 4 : 0; const SDL_Color base = !enabled ? SDL_Color{230, 232, 239, 255} : (green ? PALE_GREEN : CREAM); const SDL_Color fg = !enabled ? SDL_Color{175, 181, 193, 255} : (green ? GREEN : ORANGE); rounded(x, y + 5 - lift + press, w, h, h / 2, SDL_Color{0, 0, 0, static_cast(pressed ? 30 : 45)}); rounded(x - 3, y - 3 - lift + press, w + 6, h + 6, (h + 6) / 2, WHITE); rounded(x, y - lift + press, w, h, h / 2, base); centered(label, x, y - lift + press, w, h, px, fg, false); if (enabled && hit(x, y, w, h)) { clickConsumed = true; if (!cue.empty()) { if (cue == "folder_open") playEvent("folder_open", cue); else if (cue == "tool_color") playEvent("tool_color", "draw_tool_btn_on"); else playEvent("ui_action", cue); } return true; } return false; } bool App::actionPill(const std::string& icon, const std::string& label, int x, int y, int w, int h, bool green, bool enabled, const std::string& cue) { const bool hovered = pointerX >= x && pointerX < x + w && pointerY >= y && pointerY < y + h; const bool pressed = enabled && pointerDown && hovered; const int lift = enabled ? hoverLift(x, y, hovered) : 0; const int press = pressed ? 4 : 0; const SDL_Color base = !enabled ? SDL_Color{230, 232, 239, 255} : (green ? PALE_GREEN : CREAM); const SDL_Color fg = !enabled ? SDL_Color{175, 181, 193, 255} : (green ? GREEN : ORANGE); rounded(x, y + 5 - lift + press, w, h, h / 2, SDL_Color{0, 0, 0, static_cast(pressed ? 30 : 45)}); rounded(x - 3, y - 3 - lift + press, w + 6, h + 6, (h + 6) / 2, WHITE); rounded(x, y - lift + press, w, h, h / 2, base); const int iconSize = std::min(32, h - 10); SDL_Texture* tex = image(icon); if (tex) { int sourceW = 0, sourceH = 0; SDL_QueryTexture(tex, nullptr, nullptr, &sourceW, &sourceH); const float scale = sourceW > 0 && sourceH > 0 ? std::min(iconSize / static_cast(sourceW), iconSize / static_cast(sourceH)) : 1.0f; const int iconW = std::max(1, static_cast(sourceW * scale)); const int iconH = std::max(1, static_cast(sourceH * scale)); if (!enabled) SDL_SetTextureAlphaMod(tex, 110); drawTexture(tex, x + 13 + (iconSize - iconW) / 2, y + (h - iconH) / 2 - lift + press, iconW, iconH); if (!enabled) SDL_SetTextureAlphaMod(tex, 255); } const int textLeft = x + (tex ? 48 : 10); centered(label, textLeft, y - lift + press, x + w - textLeft, h, 19, fg, false); if (enabled && hit(x, y, w, h)) { clickConsumed = true; if (!cue.empty()) playEvent("ui_action", cue); return true; } return false; } bool App::dangerPill(const std::string& label, int x, int y, int w, int h, int px, bool enabled) { const bool hovered = pointerX >= x && pointerX < x + w && pointerY >= y && pointerY < y + h; const bool pressed = enabled && pointerDown && hovered; const int lift = enabled ? hoverLift(x, y, hovered) : 0; const int press = pressed ? 4 : 0; const SDL_Color base = enabled ? SDL_Color{218, 47, 57, 255} : SDL_Color{230, 232, 239, 255}; const SDL_Color fg = enabled ? WHITE : SDL_Color{175, 181, 193, 255}; rounded(x, y + 5 - lift + press, w, h, h / 2, SDL_Color{0, 0, 0, static_cast(pressed ? 30 : 45)}); rounded(x - 3, y - 3 - lift + press, w + 6, h + 6, (h + 6) / 2, WHITE); rounded(x, y - lift + press, w, h, h / 2, base); centered(label, x, y - lift + press, w, h, px, fg, false); if (enabled && hit(x, y, w, h)) { clickConsumed = true; playEvent("ui_action", "button_on"); return true; } return false; } bool App::dangerActionPill(const std::string& icon, const std::string& label, int x, int y, int w, int h, int px, bool enabled) { const bool hovered = pointerX >= x && pointerX < x + w && pointerY >= y && pointerY < y + h; const bool pressed = enabled && pointerDown && hovered; const int lift = enabled ? hoverLift(x, y, hovered) : 0; const int press = pressed ? 4 : 0; const SDL_Color base = enabled ? SDL_Color{218, 47, 57, 255} : SDL_Color{230, 232, 239, 255}; const SDL_Color fg = enabled ? WHITE : SDL_Color{175, 181, 193, 255}; rounded(x, y + 5 - lift + press, w, h, h / 2, SDL_Color{0, 0, 0, static_cast(pressed ? 30 : 45)}); rounded(x - 3, y - 3 - lift + press, w + 6, h + 6, (h + 6) / 2, WHITE); rounded(x, y - lift + press, w, h, h / 2, base); SDL_Texture* tex = image(icon); if (tex) { int sourceW = 0, sourceH = 0; SDL_QueryTexture(tex, nullptr, nullptr, &sourceW, &sourceH); const int iconSize = std::min(32, h - 10); const float scale = sourceW > 0 && sourceH > 0 ? std::min(iconSize / static_cast(sourceW), iconSize / static_cast(sourceH)) : 1.0f; const int iconW = std::max(1, static_cast(sourceW * scale)); const int iconH = std::max(1, static_cast(sourceH * scale)); if (!enabled) SDL_SetTextureAlphaMod(tex, 110); drawTexture(tex, x + 13 + (iconSize - iconW) / 2, y + (h - iconH) / 2 - lift + press, iconW, iconH); if (!enabled) SDL_SetTextureAlphaMod(tex, 255); } const int textLeft = x + (tex ? 48 : 10); centered(label, textLeft, y - lift + press, x + w - textLeft, h, px, fg, false); if (enabled && hit(x, y, w, h)) { clickConsumed = true; playEvent("ui_action", "button_on"); return true; } return false; } bool App::imagePill(const std::string& icon, int x, int y, int w, int h, bool green, bool enabled, const std::string& cue) { const bool hovered = pointerX >= x && pointerX < x + w && pointerY >= y && pointerY < y + h; const bool pressed = enabled && pointerDown && hovered; const int lift = enabled ? hoverLift(x, y, hovered) : 0; const int press = pressed ? 4 : 0; const SDL_Color base = !enabled ? SDL_Color{230, 232, 239, 255} : (green ? PALE_GREEN : CREAM); rounded(x, y + 5 - lift + press, w, h, h / 2, SDL_Color{0, 0, 0, static_cast(pressed ? 30 : 45)}); rounded(x - 3, y - 3 - lift + press, w + 6, h + 6, (h + 6) / 2, WHITE); rounded(x, y - lift + press, w, h, h / 2, base); SDL_Texture* tex = image(icon); if (tex) { int sourceW = 0, sourceH = 0; SDL_QueryTexture(tex, nullptr, nullptr, &sourceW, &sourceH); const float scale = sourceW > 0 && sourceH > 0 ? std::min((w - 18) / static_cast(sourceW), (h - 12) / static_cast(sourceH)) : 1.0f; const int iconW = std::max(1, static_cast(sourceW * scale)); const int iconH = std::max(1, static_cast(sourceH * scale)); if (!enabled) SDL_SetTextureAlphaMod(tex, 110); drawTexture(tex, x + (w - iconW) / 2, y + (h - iconH) / 2 - lift + press, iconW, iconH); if (!enabled) SDL_SetTextureAlphaMod(tex, 255); } if (enabled && hit(x, y, w, h)) { clickConsumed = true; if (!cue.empty()) playEvent("ui_action", cue); return true; } return false; } bool App::imageButton(const std::string& path, int x, int y, int w, int h, const std::string& cue) { const bool hovered = pointerX >= x && pointerX < x + w && pointerY >= y && pointerY < y + h; const bool pressed = pointerDown && hovered; drawImage(path, x, y - hoverLift(x, y, hovered) + (pressed ? 4 : 0), w, h); if (hit(x, y, w, h)) { clickConsumed = true; if (!cue.empty()) playEvent("ui_action", cue); return true; } return false; } void App::background() { if (screen == Screen::Canvas) fill(0, 0, W, H, WHITE); else drawImage("ui/GridBackground.png", 0, 0, W, H); } void App::renderToast() { if (toast.empty() || SDL_GetTicks() > toastUntil) return; TTF_Font* body = font(19, false); if (!body) return; constexpr int maxTextWidth = W - 120; auto measure = [body](const std::string& value) { int width = 0, height = 0; TTF_SizeUTF8(body, value.c_str(), &width, &height); return width; }; std::vector lines; std::istringstream words(toast); std::string word, line; while (words >> word) { const std::string candidate = line.empty() ? word : line + " " + word; if (!line.empty() && measure(candidate) > maxTextWidth) { lines.push_back(line); line = word; } else line = candidate; } if (!line.empty()) lines.push_back(line); if (lines.empty()) return; int textWidth = 0; for (const auto& part : lines) textWidth = std::max(textWidth, measure(part)); const int lineHeight = TTF_FontHeight(body) + 2; const int width = std::min(W - 48, textWidth + 48); const int height = static_cast(lines.size()) * lineHeight + 22; const int x = (W - width) / 2; const int y = 82; rounded(x, y, width, height, std::min(23, height / 2), SDL_Color{20, 24, 30, 225}); for (std::size_t i = 0; i < lines.size(); ++i) { const int lineWidth = measure(lines[i]); rawText(lines[i], (W - lineWidth) / 2, y + 10 + static_cast(i) * lineHeight, 19, WHITE, false); } } void App::run() { logStartup("startup: entering main loop"); const std::uint32_t start = SDL_GetTicks(); while (running #ifdef __SWITCH__ && appletMainLoop() #endif ) { poll(); pulse = static_cast(SDL_GetTicks() - start) / 1000.0f; update(); render(); if (updateInstallPending && static_cast(SDL_GetTicks() - updateInstallReadyAt) >= 0) completeUpdateInstall(); } logStartup("startup: main loop ended"); } void App::startUpdateCheck(bool manual) { if (updateInProgress || updateThread) return; if (manual && !internetConnected()) { showToast("You're not connected to the internet."); return; } updateTask = UpdateTask::Check; updateManualCheck = manual; updateAvailable = false; updateNoChange = false; updateServerVersion.clear(); updateDownloadUrl.clear(); updateSha256.clear(); updateError.clear(); updateHttpStatus = 0; updateWorkerDone.store(false, std::memory_order_relaxed); updateInProgress = true; beginLoading(); updateStartedAt = SDL_GetTicks(); updateThread = SDL_CreateThread(updateThreadEntry, "switchnote-update", this); if (!updateThread) { updateInProgress = false; endLoading(); loadingCompletionVisible = false; updateError = "Could not start the update check."; if (manual) showToast(updateError); } } void App::startUpdateDownload() { if (updateInProgress || updateThread || updateDownloadUrl.empty()) return; updateTask = UpdateTask::Download; updateManualCheck = false; updateError.clear(); updateDownloadedPath.clear(); updateHttpStatus = 0; updateWorkerDone.store(false, std::memory_order_relaxed); updateInProgress = true; modal = Modal::None; beginLoading(); updateStartedAt = SDL_GetTicks(); updateThread = SDL_CreateThread(updateThreadEntry, "switchnote-download", this); if (!updateThread) { updateInProgress = false; endLoading(); loadingCompletionVisible = false; showToast("Could not start the update download."); } } bool App::requestUpdateHandoff() { #ifdef __SWITCH__ const std::string installTarget = join(dataRoot, "switchnote.nro"); if (!envHasNextLoad()) { updateError = "This launch method cannot open the downloaded update automatically."; return false; } if (!sameSdPath(launchNroPath, installTarget)) { updateError = "Auto-install is only available when launched from switch/Switchnote/switchnote.nro."; logStartup("update: launch path did not match install target; loader path=" + (launchNroPath.empty() ? std::string("") : launchNroPath) + "; expected=" + installTarget); return false; } if (!validNroFile(updateDownloadedPath)) { updateError = "The downloaded update file could not be verified."; return false; } const std::string nextArguments = updateDownloadedPath + " --switchnote-update-handoff"; const Result result = envSetNextLoad(updateDownloadedPath.c_str(), nextArguments.c_str()); if (R_FAILED(result)) { updateError = "Could not start the downloaded update."; logStartup("update: envSetNextLoad failed: " + std::to_string(static_cast(result))); return false; } logStartup("update: chained to downloaded NRO " + updateDownloadedPath); running = false; return true; #else return false; #endif } void App::completeUpdateInstall() { updateInstallPending = false; #ifdef __SWITCH__ const std::string installTarget = join(dataRoot, "switchnote.nro"); const std::string backupPath = installTarget + ".oldbk"; auto exists = [](const std::string& path) { struct stat st{}; return stat(path.c_str(), &st) == 0; }; auto fail = [&](const std::string& message) { updateError = message; logStartup("update: " + message); showToast(message); }; if (!sameSdPath(updateInstallSourcePath, launchNroPath) || !validNroFile(updateInstallSourcePath)) { fail("The running update file could not be verified; the installed app was left unchanged."); return; } if (exists(backupPath)) { if (validNroFile(installTarget)) { if (std::remove(backupPath.c_str()) != 0) { fail("Could not clear the previous update backup; the installed app was left unchanged."); return; } } else { if (exists(installTarget)) std::remove(installTarget.c_str()); if (!validNroFile(backupPath) || std::rename(backupPath.c_str(), installTarget.c_str()) != 0) { fail("Could not restore the previous app backup; the update was not installed."); return; } } } if (!validNroFile(installTarget)) { fail("The installed Switchnote NRO was not found; the update was left in the updates folder."); return; } if (std::rename(installTarget.c_str(), backupPath.c_str()) != 0) { fail("Could not back up the current Switchnote NRO; the update was left in the updates folder."); return; } if (std::rename(updateInstallSourcePath.c_str(), installTarget.c_str()) != 0) { const bool restored = std::rename(backupPath.c_str(), installTarget.c_str()) == 0; fail(restored ? "Could not install the update; the previous app was restored." : "Could not install the update or restore the backup. Keep the .oldbk file for recovery."); return; } if (!validNroFile(installTarget)) { std::remove(installTarget.c_str()); const bool restored = std::rename(backupPath.c_str(), installTarget.c_str()) == 0; fail(restored ? "The new NRO failed verification; the previous app was restored." : "The new NRO failed verification and rollback failed. Keep the .oldbk file for recovery."); return; } if (std::remove(backupPath.c_str()) == 0) { logStartup("update: installed new NRO and removed backup"); showToast("Update installed successfully."); } else { logStartup("update: installed new NRO; backup cleanup failed"); showToast("Update installed. The old backup could not be removed."); } #endif } int App::updateThreadEntry(void* context) { App* app = static_cast(context); app->runUpdateTask(); app->updateWorkerDone.store(true, std::memory_order_release); return 0; } void App::runUpdateTask() { std::vector response; std::string responseHeaders; std::string error; if (updateTask == UpdateTask::Check) { const bool ok = galleryRequest("GET", "/api/update", {}, "application/json", response, updateHttpStatus, error, &responseHeaders); if (!ok || updateHttpStatus == 503) { updateError = updateHttpStatus == 503 ? "Update service is temporarily unavailable." : error; return; } const std::string json(response.begin(), response.end()); updateServerVersion = jsonStringField(json, "version"); updateDownloadUrl = jsonStringField(json, "download_url"); updateSha256 = jsonStringField(json, "sha256"); if (!validSha256Hex(updateSha256)) updateSha256.clear(); std::vector returnedParts, installedParts; if (updateServerVersion.empty() || updateDownloadUrl.empty() || !versionParts(updateServerVersion, returnedParts) || !versionParts(kInstalledAppVersion, installedParts)) { updateError = "The update service returned invalid version information."; return; } updateAvailable = compareVersions(updateServerVersion, kInstalledAppVersion) > 0; updateNoChange = !updateAvailable; if (updateAvailable) { std::string trustedPath; if (!trustedUpdatePath(updateDownloadUrl, trustedPath)) { updateAvailable = false; updateNoChange = false; updateError = "The update service returned an untrusted download address."; } } return; } if (updateTask != UpdateTask::Download) return; std::string downloadPath; if (!trustedUpdatePath(updateDownloadUrl, downloadPath)) { updateError = "The update service returned an untrusted download address."; return; } bool downloaded = false; for (int attempt = 0; attempt < 3; ++attempt) { response.clear(); responseHeaders.clear(); error.clear(); const bool ok = galleryRequest("GET", downloadPath, {}, "application/octet-stream", response, updateHttpStatus, error, &responseHeaders); if (ok) { downloaded = true; break; } if (updateHttpStatus == 503) { updateError = "The update download is temporarily unavailable. Try again later."; return; } if (updateHttpStatus != 429) { updateError = error.empty() ? "The update download failed." : error; return; } const int waitSeconds = retryAfterSeconds(responseHeaders); if (waitSeconds < 0 || attempt == 2) { updateError = "The download limit was reached. Please try again later."; return; } for (int second = 0; second < waitSeconds; ++second) SDL_Delay(1000); } if (!downloaded) { updateError = "The update download failed."; return; } if (response.size() < 20 || response.size() > 72U * 1024U * 1024U || std::memcmp(response.data() + 16, "NRO0", 4) != 0) { updateError = "The downloaded file is not a valid Switchnote NRO."; return; } std::string versionComponent = updateServerVersion; if (versionComponent.rfind("Ver ", 0) == 0) versionComponent.erase(0, 4); else if (!versionComponent.empty() && (versionComponent[0] == 'v' || versionComponent[0] == 'V')) versionComponent.erase(0, 1); const std::string directory = join(dataRoot, "updates"); ensureDir(directory); std::string destination = join(directory, "Switchnote-" + versionComponent + ".nro"); struct stat existing{}; for (unsigned suffix = 1; stat(destination.c_str(), &existing) == 0 && suffix < 1000; ++suffix) destination = join(directory, "Switchnote-" + versionComponent + "-" + std::to_string(suffix) + ".nro"); const std::string temporary = destination + ".part"; std::remove(temporary.c_str()); std::ofstream output(temporary, std::ios::binary | std::ios::trunc); if (!output) { updateError = "Could not create the update file on the SD card."; return; } output.write(reinterpret_cast(response.data()), static_cast(response.size())); output.flush(); const bool writeOk = static_cast(output); output.close(); if (!writeOk || std::rename(temporary.c_str(), destination.c_str()) != 0) { std::remove(temporary.c_str()); updateError = "Could not save the update to the SD card."; return; } updateDownloadedPath = destination; } void App::consumeUpdateTask() { if (!updateInProgress || !updateWorkerDone.load(std::memory_order_acquire) || SDL_GetTicks() - updateStartedAt < 450) return; if (updateThread) { SDL_WaitThread(updateThread, nullptr); updateThread = nullptr; } const UpdateTask finishedTask = updateTask; const bool wasManual = updateManualCheck; updateTask = UpdateTask::None; updateInProgress = false; endLoading(); loadingCompletionVisible = false; if (finishedTask == UpdateTask::Check) { if (updateAvailable) { if (wasManual) { modal = Modal::UpdateAvailable; modalTransitionStart = SDL_GetTicks(); } else updateOfferPending = true; } else if (wasManual && updateNoChange) showToast("This version is up to date."); else if (wasManual) showToast(updateError.empty() ? "Couldn't check for updates. Try again later." : updateError); } else if (finishedTask == UpdateTask::Download) { if (!updateDownloadedPath.empty()) { if (!requestUpdateHandoff()) showToast(updateError.empty() ? "Update saved to " + updateDownloadedPath + ". Launch that NRO manually." : updateError); } else showToast(updateError.empty() ? "The update download failed." : updateError); } } void App::update() { const std::uint32_t now = SDL_GetTicks(); consumeUpdateTask(); updateFillWave(now); consumeServerStart(); consumePhotoProbe(); consumeGalleryLoad(); if (screen != Screen::Canvas) canvasMenuOpen = false; if (screen.phase == ScreenState::Phase::ToWhite && lastScreenCueStart != screen.phaseStart) { lastScreenCueStart = screen.phaseStart; playEvent("screen_swap", "screen_swap"); } screen.advance(now); if (updateOfferPending && screen.active == Screen::Title && screen.phase == ScreenState::Phase::Idle) { updateOfferPending = false; modal = Modal::UpdateAvailable; modalTransitionStart = now; } if (modalClosing) { if (now - modalTransitionStart >= 150) { modalClosing = false; modalVisual = Modal::None; } else return; } if (!serverStartRunning) consumeAudioUpload(); if (!serverStartRunning) consumeBrandingConfig(); advancePhotoImport(); if (photoImporting) return; updatePlayback(now); if ((screen != Screen::Canvas && screen != Screen::Sequence) || modal != Modal::None || !(buttonsHeld & BRIGHT) || (screen == Screen::Canvas && (buttonsHeld & BY))) { rightDpadRepeating = false; } else if (rightDpadRepeating) { const std::uint32_t interval = std::max(1, static_cast(std::lround(1000.0 / sppm::frameRate(doc.metadata)))); while (static_cast(now - rightDpadNextRepeatAt) >= 0) { if (frame + 1 < doc.frames.size()) setFrame(frame + 1); else insertFrame(false); rightDpadNextRepeatAt += interval; } } if (modal == Modal::Photo) updatePhotoCapture(); if (screen.transitioning()) return; if (dirty && SDL_GetTicks() - dirtyTick > 1500) saveDocument(); auto canvasInputAllowed = [&]() { if (pointerX < 0 || pointerX >= W || pointerY < 0 || pointerY >= H) return false; if (canvasControlGesture || paintMenu || colorMenu || toolMenu) return false; const float scale = 2.0f * zoom; const int canvasW = static_cast(sppm::kWidth * scale); const int canvasH = static_cast(sppm::kHeight * scale); const int canvasX = (W - canvasW) / 2 + panX; const int canvasY = (H - canvasH) / 2 + panY; if (pointerX < canvasX || pointerX >= canvasX + canvasW || pointerY < canvasY || pointerY >= canvasY + canvasH) return false; return true; }; auto canvasPixelAt = [&](int sx, int sy, int& px, int& py) { const float scale = 2.0f * zoom; const int canvasW = static_cast(sppm::kWidth * scale); const int canvasH = static_cast(sppm::kHeight * scale); const int canvasX = (W - canvasW) / 2 + panX; const int canvasY = (H - canvasH) / 2 + panY; if (sx < canvasX || sx >= canvasX + canvasW || sy < canvasY || sy >= canvasY + canvasH) return false; px = static_cast((sx - canvasX) / (canvasW / static_cast(sppm::kWidth))); py = static_cast((sy - canvasY) / (canvasH / static_cast(sppm::kHeight))); return px >= 0 && px < sppm::kWidth && py >= 0 && py < sppm::kHeight; }; if (screen == Screen::Canvas && docLoaded && zoom > 1.0f && !drawing && modal == Modal::None) { if (!lastPanTick) lastPanTick = now; const float elapsed = std::min(0.050f, (now - lastPanTick) / 1000.0f); const int maxPanX = std::max(0, (static_cast(sppm::kWidth * 2.0f * zoom) - W) / 2); const int maxPanY = std::max(0, (static_cast(sppm::kHeight * 2.0f * zoom) - H) / 2); constexpr float panPixelsPerSecond = 720.0f; panX = std::clamp(panX + static_cast(std::lround(leftStickX * panPixelsPerSecond * elapsed)), -maxPanX, maxPanX); panY = std::clamp(panY + static_cast(std::lround(leftStickY * panPixelsPerSecond * elapsed)), -maxPanY, maxPanY); } lastPanTick = now; if (screen == Screen::Canvas && docLoaded && pointerPressed && modal == Modal::None && canvasInputAllowed()) { int px = 0, py = 0; if (canvasPixelAt(pointerX, pointerY, px, py)) { afterStrokePoints.clear(); realtimeStrokeInitialized = false; canvasTapPending = true; canvasFillCommitted = false; canvasPressScreenX = pointerX; canvasPressScreenY = pointerY; canvasPressPixelX = px; canvasPressPixelY = py; canvasPressStartedAt = now; drawing = true; // lets the chrome slide away immediately, before ink is committed hasLastStroke = false; } } if (screen == Screen::Canvas && docLoaded && canvasTapPending && pointerDown && modal == Modal::None) { const int touchDx = pointerX - canvasPressScreenX; const int touchDy = pointerY - canvasPressScreenY; if (touchDx * touchDx + touchDy * touchDy > 25) { // Five display pixels of touch jitter do not cancel a stationary fill. canvasTapPending = false; pushUndo(); hasLastStroke = false; strokeAt(canvasPressScreenX, canvasPressScreenY); strokeAt(pointerX, pointerY); } else if (now - canvasPressStartedAt >= 450) { canvasTapPending = false; canvasFillCommitted = true; beginFillWave(canvasPressPixelX, canvasPressPixelY); } } else if (screen == Screen::Canvas && docLoaded && pointerDown && modal == Modal::None && !canvasTapPending) { if (canvasInputAllowed()) { if (!drawing) { pushUndo(); drawing = true; hasLastStroke = false; } strokeAt(pointerX, pointerY); } else hasLastStroke = false; } if (screen == Screen::Canvas && docLoaded && pointerReleased && modal == Modal::None) { if (canvasTapPending) { // A short stationary tap remains a normal single-point stroke. pushUndo(); hasLastStroke = false; strokeAt(canvasPressScreenX, canvasPressScreenY); } else if (!canvasFillCommitted && canvasInputAllowed()) { if (!drawing) { pushUndo(); drawing = true; hasLastStroke = false; } strokeAt(pointerX, pointerY); } finishAfterStroke(); canvasTapPending = false; canvasFillCommitted = false; drawing = false; hasLastStroke = false; canvasControlGesture = false; } if (screen == Screen::CommentCanvas && pointerDown && modal == Modal::None) { if (pointerY >= 72 && pointerY < 620) { if (!drawing) { drawing = true; hasLastStroke = false; } strokeAt(pointerX, pointerY, true); } } if (screen == Screen::CommentCanvas && pointerReleased && modal == Modal::None) { const bool onButtons = pointerY < 72 || pointerY >= 620; if (!onButtons) strokeAt(pointerX, pointerY, true); drawing = false; hasLastStroke = false; } if (lastCanvasMenuTick == 0) lastCanvasMenuTick = now; if (screen == Screen::GalleryComments) { if (pointerPressed && pointerX >= 197 && pointerX < 1060 && pointerY >= 130 && pointerY < 620) { galleryCommentTouchScroll = true; galleryCommentTouchStartY = pointerY; galleryCommentTouchStartOffset = galleryCommentScroll; } if (galleryCommentTouchScroll && pointerDown) { galleryCommentScroll = std::max(0, galleryCommentTouchStartOffset + galleryCommentTouchStartY - pointerY); } if (pointerReleased) galleryCommentTouchScroll = false; const float elapsed = lastGalleryCommentScrollTick == 0 ? 0.0f : std::min(0.05f, (now - lastGalleryCommentScrollTick) / 1000.0f); if (!galleryCommentTouchScroll && std::abs(leftStickY) > 0.18f) { galleryCommentScroll = std::max(0, galleryCommentScroll + static_cast(std::lround(leftStickY * 700.0f * elapsed))); } lastGalleryCommentScrollTick = now; } else { galleryCommentTouchScroll = false; lastGalleryCommentScrollTick = now; } constexpr float menuAnimationMs = 125.0f / 1.75f; const float menuStep = std::min(0.05f, (now - lastCanvasMenuTick) / 1000.0f) / (menuAnimationMs / 1000.0f); lastCanvasMenuTick = now; canvasMenuProgress = std::clamp(canvasMenuProgress + (canvasMenuOpen ? menuStep : -menuStep), 0.0f, 1.0f); if (lastChromeTick == 0) lastChromeTick = now; const float chromeStep = std::min(1.0f, (now - lastChromeTick) / kCanvasChromeMs); lastChromeTick = now; const float chromeTarget = screen == Screen::Canvas && drawing ? 0.0f : 1.0f; if (chromeTarget < canvasChromeVisible) canvasChromeVisible = std::max(chromeTarget, canvasChromeVisible - chromeStep); else canvasChromeVisible = std::min(chromeTarget, canvasChromeVisible + chromeStep); handleKeys(); } void App::handleKeys() { if (screen == Screen::AppletWarning) { if (buttonsDown & (BA | BB | BPLUS)) running = false; return; } if (updateInProgress) return; if (modal == Modal::UpdateAvailable) { if (buttonsDown & BB) { modal = Modal::None; playEvent("cancel", "cursor_cancel"); } else if (buttonsDown & BA) startUpdateDownload(); return; } if (modal == Modal::TextInput) { if (editingField == Field::None) return; return; } if (modal == Modal::GalleryNotice) { if (buttonsDown & BB) { modal = Modal::None; playEvent("cancel", "cursor_cancel"); } else if (buttonsDown & BA) confirmGalleryLeave(); return; } if (modal == Modal::GalleryTerms) { if (buttonsDown & BB) { modal = Modal::None; screen = Screen::Library; } else if (buttonsDown & BA) { galleryTermsAccepted = true; std::ofstream accepted(join(dataRoot, "gallery-terms-accepted"), std::ios::binary | std::ios::trunc); if (accepted) accepted << "1\n"; modal = Modal::None; } return; } if (modal == Modal::GalleryOffline) { if (buttonsDown & (BB | BA)) { modal = Modal::None; playEvent("cancel", "cursor_cancel"); } return; } if (modal == Modal::ConfirmDelete) { if (buttonsDown & BB) { modal = Modal::None; playEvent("cancel", "cursor_cancel"); } else if (buttonsDown & BZR) deleteDocument(); return; } if (modal == Modal::GalleryPostChoice) { if (buttonsDown & BB) { modal = Modal::None; playEvent("cancel", "cursor_cancel"); } else if (buttonsDown & BX) { modal = Modal::None; performGalleryUpload(pendingGalleryPublishIndex, true); } else if (buttonsDown & BY) { modal = Modal::None; performGalleryUpload(pendingGalleryPublishIndex, false); } return; } if (modal == Modal::PhotoProbe && (buttonsDown & BB)) return; if ((modal == Modal::Photo || modal == Modal::PhotoChoice || modal == Modal::PhotoAlbum || modal == Modal::PhotoUpload) && (buttonsDown & BB)) { if (modal == Modal::PhotoAlbum) { modal = Modal::PhotoChoice; return; } if (serverStartRunning && serverStartTask == ServerStartTask::Photo) return; closePhotoCapture(); return; } if (modal == Modal::PhotoChoice) { if (buttonsDown & BA) { openPhotoAlbum(); } else if (buttonsDown & BY) { openPhotoUpload(); photoModalTransitionDim = true; photoModalTransitionDimAt = SDL_GetTicks(); } else if ((buttonsDown & BX) && photoProbeHasCamera) { modal = Modal::Photo; modalTransitionStart = SDL_GetTicks(); } return; } if (modal == Modal::Audio && (buttonsDown & BB)) { if (serverStartRunning && serverStartTask == ServerStartTask::Audio) return; closeAudioMenu(); return; } if (modal == Modal::Branding && (buttonsDown & BB)) { if (serverStartRunning && serverStartTask == ServerStartTask::Branding) return; closeBrandingMenu(); return; } if (modal != Modal::None) { if (buttonsDown & BB) { modal = Modal::None; playEvent("cancel", "cursor_cancel"); return; } if (modal == Modal::Options) { int speed = docLoaded ? documentSpeed(doc.metadata) : defaultSpeed; if (buttonsDown & BLEFT) speed = std::max(1, speed - 1); if (buttonsDown & BRIGHT) speed = std::min(8, speed + 1); if (buttonsDown & (BLEFT | BRIGHT)) { defaultSpeed = speed; defaultFps = kSpeedFps[static_cast(speed - 1)]; if (docLoaded) { doc.metadata.fps = static_cast(defaultFps); doc.metadata.speed = static_cast(speed); markDirty(); if (doc.metadata.bgmFollowsSpeed) rebuildAudio(); } savePreferences(); } if (buttonsDown & BUP) { onion = !onion; savePreferences(); } if (buttonsDown & BDOWN) { canvasGrid = !canvasGrid; savePreferences(); } if (buttonsDown & BY) { copyToNewPage = !copyToNewPage; savePreferences(); } if ((buttonsDown & BX) && docLoaded) { doc.metadata.loopPlayback = !doc.metadata.loopPlayback; markDirty(); } if (buttonsDown & BA) modal = Modal::None; return; } if (modal == Modal::Audio) { if (buttonsDown & BLEFT) { audioTrack = std::max(0, audioTrack - 1); audioEraseArmedTrack = -1; } if (buttonsDown & BRIGHT) { audioTrack = std::min(3, audioTrack + 1); audioEraseArmedTrack = -1; } if ((buttonsDown & BY) && audioTrack == 0) { doc.metadata.bgmFollowsSpeed = !doc.metadata.bgmFollowsSpeed; markDirty(); rebuildAudio(); } if (buttonsDown & BA) { if (audioTrack < static_cast(doc.audioTracks.size()) && !doc.audioTracks[static_cast(audioTrack)].pcm.empty()) { if (Mix_Playing(4)) Mix_HaltChannel(4); else if (audioReady && trackChunks[static_cast(audioTrack)]) Mix_PlayChannel(4, trackChunks[static_cast(audioTrack)], 0); } else showToast("No audio in this track yet"); } if ((buttonsDown & (BPLUS | BMINUS)) && audioTrack > 0) { while (doc.audioTracks.size() <= static_cast(audioTrack)) { sppm::AudioTrack blank; blank.name = "SE" + std::to_string(doc.audioTracks.size()); blank.sampleRate = 48000; blank.channels = 2; doc.audioTracks.push_back(std::move(blank)); } auto& triggers = doc.audioTracks[static_cast(audioTrack)].frameTriggers; const auto cue = static_cast(frame); auto it = std::lower_bound(triggers.begin(), triggers.end(), cue); if (it != triggers.end() && *it == cue) triggers.erase(it); else triggers.insert(it, cue); markDirty(); } return; } if (modal == Modal::Folder) { if (buttonsDown & BUP) modalSelection = std::max(0, modalSelection - 1); if (buttonsDown & BDOWN) modalSelection = std::min(5, modalSelection + 1); if (buttonsDown & BA) { std::vector folders; if (DIR* dir = opendir(notesRoot.c_str())) { while (dirent* ent = readdir(dir)) { const std::string name = ent->d_name; if (name.empty() || name[0] == '.') continue; struct stat st{}; if (stat(join(notesRoot, name).c_str(), &st) == 0 && (st.st_mode & S_IFDIR)) folders.push_back(name); } closedir(dir); } std::sort(folders.begin(), folders.end()); if (!folders.empty()) { folder = folders[static_cast(modalSelection) % folders.size()]; selected = 0; scanLibrary(); modal = Modal::None; playEvent("folder_open", "folder_open"); } } return; } if (modal == Modal::Export && (buttonsDown & (BUP | BDOWN | BLEFT | BRIGHT))) modalSelection = (modalSelection + 1) % 2; if ((modal == Modal::ConfirmDelete || modal == Modal::ConfirmGalleryDelete) && (buttonsDown & (BLEFT | BRIGHT))) modalSelection = (modalSelection + 1) % 2; if (buttonsDown & BA) { if (modal == Modal::Export) { exportDocument(modalSelection); modal = Modal::None; } else if (modal == Modal::ConfirmGalleryDelete) { if (modalSelection == 1) deleteGalleryItem(); else modal = Modal::None; } else if (modal == Modal::Help) modal = Modal::None; else if (modal == Modal::TextInput) applyText(); } return; } if (buttonsDown & BB) { switch (screen) { case Screen::Title: break; case Screen::Library: screen = Screen::Title; return; case Screen::Details: screen = Screen::Library; return; case Screen::Sequence: screen = Screen::Canvas; return; case Screen::Canvas: layer = (layer + 1) % 4; playEvent("tool_layer", "draw_tool_btn_on"); return; case Screen::Viewer: stopPlayback(); screen = galleryViewing ? Screen::GalleryDetails : Screen::Details; return; case Screen::Settings: screen = settingsReturnScreen; return; case Screen::Gallery: screen = Screen::Library; return; case Screen::GalleryDetails: if (galleryProfileDetailMode) { galleryProfileDetailMode = false; screen = Screen::GalleryProfile; } else screen = Screen::Gallery; return; case Screen::GalleryProfile: leaveGalleryProfile(); return; case Screen::GalleryComments: screen = Screen::GalleryDetails; return; case Screen::CommentCanvas: screen = Screen::GalleryComments; return; case Screen::AppletWarning: running = false; return; } } if (buttonsDown & BMINUS) { if (screen == Screen::Title) return; if (screen == Screen::Settings) { screen = settingsReturnScreen; return; } if (screen == Screen::Library || screen == Screen::Gallery) screen = Screen::Title; else if (screen == Screen::Canvas || screen == Screen::Viewer) { stopPlayback(); screen = Screen::Sequence; } else if (screen == Screen::GalleryComments) screen = Screen::GalleryDetails; else if (screen == Screen::CommentCanvas) screen = Screen::GalleryComments; else if (screen == Screen::Sequence) { closeDocument(); screen = Screen::Library; } else if (screen == Screen::Details || screen == Screen::GalleryDetails) { if (screen == Screen::GalleryDetails && galleryProfileDetailMode) { galleryProfileDetailMode = false; screen = Screen::GalleryProfile; } else screen = (screen == Screen::GalleryDetails ? Screen::Gallery : Screen::Library); } } if (screen == Screen::Title) { if (buttonsDown & BR) { settingsReturnScreen = Screen::Title; screen = Screen::Settings; return; } if ((buttonsDown & (BZL | BZR)) == (BZL | BZR) || (buttonsHeld & (BZL | BZR)) == (BZL | BZR) || (buttonsDown & BA)) { screen = Screen::Library; selected = 0; playEvent("folder_open", "folder_open"); } } else if (screen == Screen::Library || screen == Screen::Gallery) { const bool galleryMode = screen == Screen::Gallery; if (!galleryMode && (buttonsDown & BZR)) { showGalleryNotice(); return; } if (galleryMode && (buttonsDown & BZL) && galleryPage > 0) { --galleryPage; gallerySelected = 0; galleryStatus = "Loading Gallery…"; refreshGallery(); return; } if (galleryMode && (buttonsDown & BZR) && (galleryPage + 1) * 6 < galleryTotal) { ++galleryPage; gallerySelected = 0; galleryStatus = "Loading Gallery…"; refreshGallery(); return; } if (!galleryMode && (buttonsDown & BR)) { settingsReturnScreen = Screen::Library; screen = Screen::Settings; return; } if (!galleryMode && (buttonsDown & BPLUS)) { newDocument(); return; } auto& selection = galleryMode ? gallerySelected : selected; const auto& items = galleryMode ? gallery : library; if (buttonsDown & BLEFT) { if (selection > 0) --selection; } if (buttonsDown & BRIGHT) { if (selection + 1 < items.size()) ++selection; } if (buttonsDown & BUP) { if (selection >= 3) selection -= 3; } if (buttonsDown & BDOWN) { if (selection + 3 < items.size()) selection += 3; } if (buttonsDown & BY) { if (!galleryMode && !items.empty()) openItem(selection, true); else if (galleryMode) addGalleryCoin(); } if (buttonsDown & BA) { if (!items.empty()) { if (galleryMode) screen = Screen::GalleryDetails; else { expandMetadata = false; screen = Screen::Details; } } } if (buttonsDown & BX && !galleryMode && !items.empty()) { modalSelection = 0; modal = Modal::ConfirmDelete; } } else if (screen == Screen::Details || screen == Screen::GalleryDetails) { const bool galleryOwner = screen == Screen::GalleryDetails && !gallery.empty() && gallery[gallerySelected % gallery.size()].ownerIdentity == sppm::identityFingerprint(signingIdentity.publicIdentity); if (buttonsDown & BX && galleryOwner) { modalSelection = 0; modal = Modal::ConfirmGalleryDelete; } if (buttonsDown & BY) { if (screen == Screen::GalleryDetails) openGalleryComments(); else { openItem(selected, true); } } if (buttonsDown & BA) { if (screen == Screen::GalleryDetails) openGalleryItem(gallerySelected); else { openItem(selected, false); screen = Screen::Viewer; } } if (buttonsDown & BZL && screen == Screen::Details && !library.empty()) { modalSelection = 0; modal = Modal::ConfirmDelete; } } else if (screen == Screen::Sequence) { if (buttonsDown & BPLUS) { settingsReturnScreen = Screen::Sequence; screen = Screen::Settings; return; } if (buttonsDown & BLEFT) setFrame(frame ? frame - 1 : 0); if (buttonsDown & BRIGHT) { const bool createsFirstHeldPage = frame + 1 >= doc.frames.size(); if (frame + 1 < doc.frames.size()) setFrame(frame + 1); else insertFrame(false); const std::uint32_t period = std::max(1, static_cast(std::lround(1000.0 / sppm::frameRate(doc.metadata)))); rightDpadNextRepeatAt = SDL_GetTicks() + period * (createsFirstHeldPage ? 3u : 1u); rightDpadRepeating = (buttonsHeld & BRIGHT) != 0; } if (buttonsDown & BUP) setFrame(0, true, "frame_first"); if (buttonsDown & BDOWN) setFrame(doc.frames.size() - 1, true, "frame_last"); if (buttonsDown & BA) startPlayback(); if (buttonsDown & BY) { if (doc.metadata.locked) showToast("This Switchnote is locked and cannot be edited"); else screen = Screen::Canvas; } if ((buttonsDown & BX) && !doc.metadata.locked) insertFrame(false); if (buttonsDown & BZL) { modalSelection = 0; modal = Modal::ConfirmDelete; } } else if (screen == Screen::Canvas || screen == Screen::Viewer) { if (screen == Screen::Viewer && (buttonsDown & BLEFT)) { if (frame) setFrame(frame - 1); } if (screen == Screen::Viewer && (buttonsDown & BRIGHT)) { const bool createsFirstHeldPage = frame + 1 >= doc.frames.size(); if (frame + 1 < doc.frames.size()) setFrame(frame + 1); else if (screen == Screen::Canvas) insertFrame(false); if (screen == Screen::Canvas && modal == Modal::None) { const std::uint32_t period = std::max(1, static_cast(std::lround(1000.0 / sppm::frameRate(doc.metadata)))); rightDpadNextRepeatAt = SDL_GetTicks() + period * (createsFirstHeldPage ? 3u : 1u); rightDpadRepeating = (buttonsHeld & BRIGHT) != 0; } } if (screen == Screen::Canvas && (buttonsDown & BUP)) { zoom = std::min(zoom * 1.25f, 8.0f); } if (screen == Screen::Canvas && (buttonsDown & BDOWN)) { zoom = std::max(zoom / 1.25f, 0.5f); } if (buttonsDown & BA) { if (playing) stopPlayback(); else startPlayback(); } if (screen == Screen::Viewer && (buttonsDown & BB)) { stopPlayback(); screen = Screen::Sequence; } if (screen == Screen::Canvas) { const bool dpadDown = (buttonsDown & (BLEFT | BRIGHT)) != 0; if (((buttonsHeld & BY) && dpadDown) || ((buttonsDown & BY) && (buttonsHeld & (BLEFT | BRIGHT))) || ((buttonsUp & BY) && dpadDown)) { canvasYChordUsed = true; if (buttonsDown & BLEFT) undo(); if (buttonsDown & BRIGHT) redo(); if ((buttonsDown & BY) && (buttonsHeld & BLEFT)) undo(); if ((buttonsDown & BY) && (buttonsHeld & BRIGHT)) redo(); } const bool suppressDpadForY = canvasYChordUsed || (buttonsHeld & BY) != 0; if ((buttonsUp & BY) && !canvasYChordUsed) { activeTool = activeTool == Tool::Pen ? Tool::Brush : activeTool == Tool::Brush ? Tool::Eraser : Tool::Pen; playEvent(activeTool == Tool::Pen ? "tool_pen" : activeTool == Tool::Brush ? "tool_brush" : "tool_eraser", "draw_tool_btn_on"); } if (buttonsUp & BY) canvasYChordUsed = false; if (buttonsDown & BZL) undo(); if (buttonsDown & BZR) redo(); if (buttonsDown & BX) { pushUndo(); auto& pixels = doc.frames[frame].layers[static_cast(layer)]; std::fill(pixels.begin(), pixels.end(), 0); markDirty(); playEvent("clear_layer", "erase"); } if ((buttonsDown & BLEFT) && !suppressDpadForY) { if (frame) setFrame(frame - 1); } if ((buttonsDown & BRIGHT) && !suppressDpadForY) { const bool createsFirstHeldPage = frame + 1 >= doc.frames.size(); if (frame + 1 < doc.frames.size()) setFrame(frame + 1); else insertFrame(false); const std::uint32_t period = std::max(1, static_cast(std::lround(1000.0 / sppm::frameRate(doc.metadata)))); rightDpadNextRepeatAt = SDL_GetTicks() + period * (createsFirstHeldPage ? 3u : 1u); rightDpadRepeating = (buttonsHeld & BRIGHT) != 0; } if (buttonsDown & BL) screen = Screen::Sequence; if (buttonsDown & BPLUS) insertFrame(false); } } else if (screen == Screen::Settings) { if (buttonsDown & BB) screen = settingsReturnScreen; } else if (screen == Screen::GalleryProfile) { const int profilePages = 1 + (galleryProfile.switchnotes + 5) / 6; if ((buttonsDown & BLEFT) && galleryProfilePage > 0) loadGalleryProfile(galleryProfile.identity, galleryProfileReturnScreen, galleryProfilePage - 1); if ((buttonsDown & BRIGHT) && galleryProfilePage + 1 < profilePages) loadGalleryProfile(galleryProfile.identity, galleryProfileReturnScreen, galleryProfilePage + 1); if ((buttonsDown & BA) && galleryProfilePage > 0 && !gallery.empty()) { galleryProfileDetailMode = true; screen = Screen::GalleryDetails; } else if ((buttonsDown & BA) && galleryProfilePage == 0 && !galleryProfileIsOwn) { updateGalleryRelationship(galleryProfile.followed ? "unfollow" : "follow"); } if (buttonsDown & BX) updateGalleryRelationship(galleryProfile.blocked ? "unblock" : "block"); if ((buttonsDown & BY) && galleryProfileIsOwn) beginText(Field::GalleryBio, galleryProfile.bio); } else if (screen == Screen::GalleryComments) { if (buttonsDown & BUP) galleryCommentScroll = std::max(0, galleryCommentScroll - 90); if (buttonsDown & BDOWN) galleryCommentScroll += 90; if (buttonsDown & BY) screen = Screen::CommentCanvas; if (buttonsDown & BB) screen = Screen::GalleryDetails; } else if (screen == Screen::CommentCanvas) { if (buttonsDown & BB) screen = Screen::GalleryComments; if (buttonsDown & BY) postGalleryComment(); if (buttonsDown & BX) { commentPixels.assign(sppm::kPixelsPerLayer, 0); commentDirty = true; } } if (wheel && screen == Screen::Canvas) zoom = std::clamp(zoom + wheel * 0.2f, 0.5f, 8.0f); if (wheel && screen == Screen::GalleryComments) galleryCommentScroll = std::max(0, galleryCommentScroll - wheel * 90); if (screen == Screen::Canvas) { const int maxPanX = std::max(0, (static_cast(sppm::kWidth * 2.0f * zoom) - W) / 2); const int maxPanY = std::max(0, (static_cast(sppm::kHeight * 2.0f * zoom) - H) / 2); panX = std::clamp(panX, -maxPanX, maxPanX); panY = std::clamp(panY, -maxPanY, maxPanY); } } void App::render() { // Some custom canvas effects alter the renderer blend mode. Restore alpha // blending every frame so loading dimmers and translucent UI always work. SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); if (screen == Screen::Canvas || screen == Screen::Viewer) SDL_SetRenderDrawColor(renderer, 255, 255, 255, 255); else SDL_SetRenderDrawColor(renderer, 245, 246, 248, 255); SDL_RenderClear(renderer); switch (screen) { case Screen::Title: renderTitle(); break; case Screen::Library: renderLibrary(); break; case Screen::Details: renderDetails(false); break; case Screen::Settings: renderSettings(); break; case Screen::Sequence: renderSequence(); break; case Screen::Canvas: renderCanvas(); break; case Screen::Viewer: renderViewer(); break; case Screen::Gallery: renderGallery(); break; case Screen::GalleryDetails: renderDetails(true); break; case Screen::GalleryProfile: renderGalleryProfile(); break; case Screen::GalleryComments: renderComments(); break; case Screen::CommentCanvas: renderCommentCanvas(); break; case Screen::AppletWarning: renderAppletWarning(); break; } if (modal != lastRenderedModal) { modalTransitionStart = SDL_GetTicks(); if (modal == Modal::None) { modalVisual = lastRenderedModal; modalClosing = modalVisual != Modal::None; } else { modalVisual = modal; modalClosing = false; playEvent("popup", "popup"); } lastRenderedModal = modal; } if (modal != Modal::None) renderModal(); else if (modalClosing) { const Modal savedModal = modal; modal = modalVisual; renderModal(); modal = savedModal; } renderToast(); const Uint8 transitionAlpha = screen.whiteAlpha(SDL_GetTicks()); if (transitionAlpha) fill(0, 0, W, H, SDL_Color{255, 255, 255, transitionAlpha}); renderLoadingOverlay(); SDL_RenderPresent(renderer); if (touchHoverExpiryPending && !pointerDown) { pointerX = pointerY = -1; touchHoverExpiryPending = false; } } void App::renderAppletWarning() { background(); const SDL_Color panel{239, 119, 122, 255}; const SDL_Color darkRed{194, 24, 24, 255}; rounded(53, 43, 1174, 640, 28, SDL_Color{0, 0, 0, 35}); rounded(59, 47, 1162, 636, 28, WHITE); rounded(61, 49, 1158, 632, 26, panel); centered("Cannot run in Applet Mode", 210, 145, 860, 100, 48, darkRed, true); centered(appDisplayName + " cannot run in Applet Mode.", 165, 268, 950, 52, 28, darkRed, false); text("Please run the app from the Homebrew Menu in App Override mode, or", 168, 366, 27, darkRed, false); text("install the homebrew NSP file.", 168, 402, 27, darkRed, false); const bool hovered = pointerX >= 566 && pointerX < 714 && pointerY >= 524 && pointerY < 584; const int press = pointerDown && hovered ? 2 : 0; rounded(578, 530 + press, 124, 53, 28, SDL_Color{0, 0, 0, 35}); rounded(572, 524 + press, 136, 54, 28, WHITE); rounded(574, 526 + press, 132, 50, 26, panel); centered("Exit", 574, 526 + press, 132, 50, 24, darkRed, false); if (hit(572, 524, 136, 54)) { clickConsumed = true; running = false; } } void App::renderTitle() { background(); if (brandingLogoTexture) { int sourceW = 0, sourceH = 0; SDL_QueryTexture(brandingLogoTexture, nullptr, nullptr, &sourceW, &sourceH); const float scale = sourceW > 0 && sourceH > 0 ? std::min(640.0f / sourceW, 218.0f / sourceH) : 1.0f; const int logoW = static_cast(sourceW * scale), logoH = static_cast(sourceH * scale); drawTexture(brandingLogoTexture, 640 - logoW / 2, 252 - logoH / 2, logoW, logoH); } else { centered(appDisplayName, 240, 190, 800, 126, 72, CREAM, true); centered(appDisplayName, 240, 184, 800, 126, 72, ORANGE, true); } if (actionPill("ui/icons/CanvasMenu_ConfigToggle.png", "Settings", 1022, 36, 214, 48, false, true, "")) { settingsReturnScreen = Screen::Title; screen = Screen::Settings; } const float focus = 0.5f + 0.5f * std::sin(pulse * 2.4f); const auto promptTone = static_cast(220 + 10 * focus); const bool promptHovered = pointerX >= 474 && pointerX < 806 && pointerY >= 469 && pointerY < 531; const bool promptPressed = pointerDown && promptHovered; const int promptLift = hoverLift(474, 469, promptHovered); const int promptPress = promptPressed ? 4 : 0; const int promptY = -promptLift + promptPress; rounded(474, 474 + promptY, 332, 64, 14, SDL_Color{0, 0, 0, 38}); rounded(474, 469 + promptY, 332, 62, 14, WHITE); rounded(480, 475 + promptY, 320, 50, 10, SDL_Color{255, promptTone, static_cast(197 + 8 * focus), 255}); centered("Press", 516, 475 + promptY, 100, 50, 25, ORANGE, true); auto promptIcon = [&](const char* path, int x) { SDL_Texture* texture = image(path); if (!texture) return; int sourceW = 0, sourceH = 0; SDL_QueryTexture(texture, nullptr, nullptr, &sourceW, &sourceH); const float iconScale = sourceW && sourceH ? std::min(42.0f / sourceW, 32.0f / sourceH) : 1.0f; const int iconW = std::max(1, static_cast(std::lround(sourceW * iconScale))); const int iconH = std::max(1, static_cast(std::lround(sourceH * iconScale))); drawTexture(texture, x + (42 - iconW) / 2, 484 + (32 - iconH) / 2 + promptY, iconW, iconH); }; promptIcon("ui/icons/ButtonZLText.png", 622); centered("+", 666, 475 + promptY, 34, 50, 25, ORANGE, true); promptIcon("ui/icons/ButtonZRText.png", 698); if (hit(474, 469, 332, 62)) { clickConsumed = true; screen = Screen::Library; selected = 0; playEvent("folder_open", "folder_open"); } centered("© 2026 RexiMemo!", 390, 634, 500, 44, 25, ORANGE, false); text("Ver 0.1.51", 1130, 663, 18, ORANGE); } void App::renderLibrary() { background(); drawImage("ui/FolderButton_Active.png", 40, 25, 68, 68); drawImage("ui/FolderButton_New.png", 122, 25, 68, 68); centered("Folders", 36, 88, 76, 21, 14, GRAY, true); centered("New Folder", 115, 88, 84, 21, 13, GRAY, true); if (pill("Switchnote Gallery", 788, 40, 205, 47, true, 19)) showGalleryNotice(); if (actionPill("ui/icons/FrameNew.png", "New Switchnote", 1017, 40, 220, 47, false, true, "")) newDocument(); if (hit(40, 24, 70, 70)) { clickConsumed = true; modalSelection = 0; modal = Modal::Folder; playEvent("folder_open", "folder_open"); } if (hit(122, 24, 70, 70)) { clickConsumed = true; beginText(Field::FolderName, ""); } const int start = static_cast(page) * 6; if (library.empty()) { rounded(305, 190, 670, 315, 20, SDL_Color{0, 0, 0, 28}); rounded(305, 182, 670, 315, 20, WHITE); drawImage("ui/icons/FrameNew.png", 594, 224, 92, 92); centered("\"" + folder + "\" is empty", 355, 328, 570, 42, 27, INK, true); centered("Switchnotes added to this folder will show here.", 345, 374, 590, 40, 18, GRAY); if (actionPill("ui/icons/FrameNew.png", "Create Switchnote", 500, 434, 280, 50)) newDocument(); } for (int cell = 0; cell < 6; ++cell) { const int index = start + cell; if (index >= static_cast(library.size())) break; const int col = cell % 3; const int row = cell / 3; const int x = 104 + col * 372; const int y = 153 + row * 232; const bool selectedNow = static_cast(index) == selected; const bool hovered = pointerX >= x - 5 && pointerX < x + 326 && pointerY >= y - 4 && pointerY < y + 194; const int lift = hoverLift(x, y, hovered); const SDL_Color border = selectedNow ? ORANGE : SDL_Color{210, 214, 220, 255}; rounded(x - 5, y + 4 - lift, 331, 198, 15, SDL_Color{0, 0, 0, 48}); rounded(x - 5, y - 4 - lift, 331, 198, 15, WHITE); // Every Switchnote preview sits on the same solid paper background, // regardless of whether its content uses transparent pixels. rounded(x + 3, y + 4 - lift, 315, 182, 8, WHITE); const Item& item = library[static_cast(index)]; if (item.sample) drawImage("art/" + item.artwork, x + 3, y + 4 - lift, 315, 182, true); else if (item.path == docPath && docLoaded) drawCurrentFrame(x + 3, y + 4 - lift, 315, 182); else drawTexture(thumbTexture(item), x + 3, y + 4 - lift, 315, 182); roundedOutline(x - 1, y - lift, 323, 190, 14, 4, border); if (!item.sample && item.locked) drawLockIcon(x + 10, y + 10 - lift); if (selectedNow) { const int pop = lift; rounded(x + 142 - pop / 2, y + 170 - pop / 2 - lift, 58 + pop, 58 + pop, 30 + pop / 2, SDL_Color{0, 0, 0, 42}); rounded(x + 138 - pop / 2, y + 164 - pop / 2 - lift, 58 + pop, 58 + pop, 30 + pop / 2, WHITE); rounded(x + 143 - pop / 2, y + 169 - pop / 2 - lift, 48 + pop, 48 + pop, 24 + pop / 2, CREAM); drawPlayTriangle(x + 158 - pop / 2, y + 182 - pop / 2 - lift, 20 + pop, ORANGE); } if (hit(x - 5, y - 4, 331, 198)) { selected = static_cast(index); page = selected / 6; clickConsumed = true; if (selectedNow) { expandMetadata = false; screen = Screen::Details; } else playCue("button_on"); } } const int totalPages = std::max(1, static_cast((library.size() + 5) / 6)); if (totalPages > 1 && page > 0 && pill("<", 25, 327, 60, 78, false, 34)) --page; if (totalPages > 1 && page + 1 < totalPages && pill(">", 1195, 327, 60, 78, false, 34)) ++page; if (imageButton("ui/BackButton.png", 39, 620, 65, 65, "button_off")) screen = Screen::Title; for (int i = 0; i < totalPages; ++i) rounded(582 + i * 18, 653, 12, 12, 6, i == page ? ORANGE : CREAM); if (actionPill("ui/icons/ButtonYText.png", "Edit", 920, 635, 126, 50, false, !library.empty())) openItem(selected, true); if (actionPill("ui/icons/ButtonAText.png", "Details", 1060, 635, 164, 50, false, !library.empty())) { expandMetadata = false; screen = Screen::Details; } if (hit(40, 25, 68, 68)) { modalSelection = 0; modal = Modal::Folder; clickConsumed = true; } if (hit(122, 25, 68, 68)) { beginText(Field::FolderName, ""); clickConsumed = true; } } void App::renderSettings() { background(); if (imageButton("ui/BackButton.png", 39, 35, 65, 65, "button_off")) screen = settingsReturnScreen; text("Settings", 132, 40, 34, INK, true); rounded(103, 139, 514, 150, 18, SDL_Color{0, 0, 0, 28}); rounded(103, 132, 514, 150, 18, WHITE); rounded(126, 165, 84, 84, 3, PALE_GREEN); if (profileTexture) drawTexture(profileTexture, 126, 165, 84, 84); else centered(userName.empty() ? "?" : utf8Prefix(userName, 1), 126, 165, 84, 84, 40, WHITE, true); text("Nickname", 232, 162, 17, GRAY, true); text(userName, 232, 196, 28, INK, true); rounded(645, 139, 532, 150, 18, SDL_Color{0, 0, 0, 28}); rounded(645, 132, 532, 150, 18, LIGHT); text("Creator FSID", 677, 158, 18, GRAY, true); text(myFsid, 677, 193, 30, SDL_Color{125, 132, 146, 255}, true); std::string freeSpace = "Unavailable"; #ifdef __SWITCH__ FsFileSystem sd{}; s64 freeBytes = 0; if (R_SUCCEEDED(fsOpenSdCardFileSystem(&sd))) { if (R_SUCCEEDED(fsFsGetFreeSpace(&sd, "/", &freeBytes))) { const double gib = static_cast(freeBytes) / (1024.0 * 1024.0 * 1024.0); std::ostringstream amount; amount.setf(std::ios::fixed); amount.precision(gib >= 10.0 ? 0 : 1); amount << gib << " GB free"; freeSpace = amount.str(); } fsFsClose(&sd); } #endif rounded(103, 329, 1074, 120, 18, SDL_Color{0, 0, 0, 28}); rounded(103, 322, 1074, 120, 18, WHITE); text("SD Free Space", 138, 350, 19, GRAY, true); text(freeSpace, 138, 385, 31, INK, true); if (pill(std::string("Pen Stabilisation: ") + (penStabilisationEnabled ? "On" : "Off"), 103, 466, 330, 48, penStabilisationEnabled, 18)) { penStabilisationEnabled = !penStabilisationEnabled; savePreferences(); } if (penStabilisationEnabled) { text("Method", 480, 474, 18, GRAY); if (pill(penStabilisationAfter ? "After" : "Real Time", 556, 466, 185, 48, true, 18)) { penStabilisationAfter = !penStabilisationAfter; afterStrokePoints.clear(); realtimeStrokeInitialized = false; savePreferences(); } text("Stabilisation", 103, 535, 18, GRAY); const int sliderX = 263, sliderY = 550, sliderW = 650; rounded(sliderX, sliderY + 8, sliderW, 8, 4, LIGHT); rounded(sliderX, sliderY + 8, sliderW * penStabilisationStrength / 100, 8, 4, GREEN); const int handleX = sliderX + sliderW * penStabilisationStrength / 100; rounded(handleX - 13, sliderY, 26, 26, 13, WHITE); rounded(handleX - 8, sliderY + 5, 16, 16, 8, GREEN); text(std::to_string(penStabilisationStrength), 932, 544, 18, GREEN); if (pointerDown && pointerX >= sliderX - 8 && pointerX <= sliderX + sliderW + 8 && pointerY >= sliderY - 8 && pointerY <= sliderY + 34) { penStabilisationStrength = std::clamp((pointerX - sliderX) * 100 / sliderW, 0, 100); } if (hit(sliderX - 8, sliderY - 8, sliderW + 16, 42)) { clickConsumed = true; penStabilisationStrength = std::clamp((pointerX - sliderX) * 100 / sliderW, 0, 100); savePreferences(); } } centered("Import from branding.config in the app folder · exports/branding.config", 300, 590, 650, 30, 15, GRAY); if (pill("Credits", 1000, 582, 177, 42, false, 17)) { modal = Modal::Credits; modalTransitionStart = SDL_GetTicks(); } if (pill("Import Branding", 103, 634, 250, 56, false, 17)) importBrandingConfig(); if (pill("Export Branding", 367, 634, 250, 56, false, 17)) exportBrandingConfig(); if (pill("Check for Updates", 631, 634, 246, 56, false, 17)) startUpdateCheck(true); if (actionPill("ui/icons/CanvasMenu_ConfigToggle.png", "Branding Config Portal", 891, 634, 286, 56, false, true, "")) { requestBrandingConfigServer(); modal = Modal::Branding; modalTransitionStart = SDL_GetTicks(); } } void App::renderGallery() { background(); const std::string sortLabel = galleryPopular ? "Popular " + notePlural : "Recent " + notePlural; if (pill(sortLabel, 40, 40, 274, 48, true, 18)) { galleryPopular = !galleryPopular; galleryPage = 0; gallerySelected = 0; galleryStatus = "Loading Gallery…"; refreshGallery(); } rounded(1173, 37, 68, 68, 12, SDL_Color{0, 0, 0, 40}); rounded(1173, 32, 68, 68, 12, WHITE); rounded(1178, 37, 58, 58, 3, PALE_GREEN); if (profileCornerTexture) drawTexture(profileCornerTexture, 1178, 37, 58, 58); else centered(userName.empty() ? "?" : utf8Prefix(userName, 1), 1178, 37, 58, 58, 30, GREEN, true); rounded(1212, 78, 29, 29, 15, SDL_Color{10, 12, 15, 255}); centered("+", 1212, 76, 29, 29, 25, WHITE, true); if (hit(1173, 32, 68, 68)) { clickConsumed = true; loadGalleryProfile(sppm::identityFingerprint(signingIdentity.publicIdentity), Screen::Gallery); } if (gallery.empty()) { rounded(330, 226, 620, 258, 20, SDL_Color{0, 0, 0, 28}); rounded(330, 218, 620, 258, 20, WHITE); const bool emptyGallery = galleryTotal == 0 && galleryStatus == "No Switchnotes have been shared yet."; centered(emptyGallery ? "No Switchnotes yet" : "Gallery unavailable", 370, 270, 540, 42, 25, INK, true); centered(emptyGallery ? "" : utf8Prefix(galleryStatus, 70), 365, 321, 550, 52, 17, GRAY); if (pill("Refresh", 520, 400, 240, 48, true, 18)) { galleryStatus = "Loading Gallery…"; refreshGallery(); } } else { for (int cell = 0; cell < 6; ++cell) { const int index = cell; if (index >= static_cast(gallery.size())) break; const int col = cell % 3; const int row = cell / 3; const int x = 104 + col * 372; const int y = 153 + row * 232; const bool selectedNow = static_cast(index) == gallerySelected; const bool hovered = pointerX >= x - 5 && pointerX < x + 326 && pointerY >= y - 4 && pointerY < y + 194; const int lift = hoverLift(x, y, hovered); const SDL_Color border = selectedNow ? GREEN : SDL_Color{210, 214, 220, 255}; rounded(x - 5, y + 4 - lift, 331, 198, 15, SDL_Color{0, 0, 0, 48}); rounded(x - 5, y - 4 - lift, 331, 198, 15, WHITE); rounded(x + 3, y + 4 - lift, 315, 182, 8, WHITE); drawTexture(galleryPreviewTexture(gallery[static_cast(index)]), x + 3, y + 4 - lift, 315, 182); roundedOutline(x - 1, y - lift, 323, 190, 12, 4, border); if (gallery[static_cast(index)].locked) drawLockIcon(x + 10, y + 10 - lift); rounded(x + 228, y + 158 - lift, 94, 30, 15, SDL_Color{255, 237, 190, 245}); drawImage("ui/icons/Coin.png", x + 236, y + 162 - lift, 22, 22); text(std::to_string(gallery[static_cast(index)].coins), x + 265, y + 162 - lift, 17, ORANGE, true); if (selectedNow) { const int pop = lift; rounded(x + 142 - pop / 2, y + 170 - pop / 2 - lift, 58 + pop, 58 + pop, 30 + pop / 2, SDL_Color{0, 0, 0, 42}); rounded(x + 138 - pop / 2, y + 164 - pop / 2 - lift, 58 + pop, 58 + pop, 30 + pop / 2, WHITE); rounded(x + 143 - pop / 2, y + 169 - pop / 2 - lift, 48 + pop, 48 + pop, 24 + pop / 2, PALE_GREEN); drawPlayTriangle(x + 158 - pop / 2, y + 182 - pop / 2 - lift, 20 + pop, WHITE); } if (hit(x - 5, y - 4, 331, 198)) { clickConsumed = true; gallerySelected = static_cast(index); if (selectedNow) screen = Screen::GalleryDetails; else playCue("button_on"); } } const int totalPages = std::max(1, (galleryTotal + 5) / 6); if (totalPages > 1 && galleryPage > 0 && pill("<", 25, 327, 60, 78, false, 34)) { --galleryPage; gallerySelected = 0; galleryStatus = "Loading Gallery…"; refreshGallery(); } if (totalPages > 1 && galleryPage + 1 < totalPages && pill(">", 1195, 327, 60, 78, false, 34)) { ++galleryPage; gallerySelected = 0; galleryStatus = "Loading Gallery…"; refreshGallery(); } if (totalPages > 1) for (int i = 0; i < totalPages; ++i) rounded(640 - totalPages * 9 + i * 18, 653, 12, 12, 6, i == galleryPage ? GREEN : PALE_GREEN); if (actionPill("ui/icons/ButtonYGalleryText.png", "Add Coins", 932, 635, 174, 50, true)) addGalleryCoin(); if (actionPill("ui/icons/ButtonAGalleryText.png", "Details", 1118, 635, 120, 50, true)) screen = Screen::GalleryDetails; } if (imageButton("ui/BackButton_Gallery.png", 39, 620, 65, 65, "button_off")) { galleryProfileActive = false; screen = Screen::Library; } } void App::renderGalleryProfile() { background(); const std::string username = galleryProfile.username.empty() ? (galleryLoadRunning ? "Loading profile…" : "Gallery User") : galleryProfile.username; if (pill(galleryProfile.followed ? "Following" : "Follow", 40, 40, 166, 48, true, 19, !galleryProfileIsOwn)) updateGalleryRelationship(galleryProfile.followed ? "unfollow" : "follow"); if (dangerPill(galleryProfile.blocked ? "Unblock" : "Block", 1074, 40, 166, 48, 19, !galleryProfileIsOwn)) updateGalleryRelationship(galleryProfile.blocked ? "unblock" : "block"); if (galleryProfileIsOwn && galleryProfilePage == 0 && pill("Edit Bio", 224, 40, 166, 48, true, 19)) { beginText(Field::GalleryBio, galleryProfile.bio); } if (galleryProfilePage == 0) { rounded(104, 146, 1072, 474, 22, SDL_Color{0, 0, 0, 30}); rounded(98, 136, 1084, 474, 22, WHITE); const int avatarX = 132, avatarY = 173, avatarSize = 164; rounded(avatarX - 4, avatarY - 4, avatarSize + 8, avatarSize + 8, 19, GREEN); rounded(avatarX, avatarY, avatarSize, avatarSize, 15, WHITE); SDL_Texture* avatar = galleryProfileIsOwn ? profileTexture : galleryProfileAvatarTexture; if (avatar) drawTexture(avatar, avatarX + 3, avatarY + 3, avatarSize - 6, avatarSize - 6); else centered(username.empty() ? "?" : utf8Prefix(username, 1), avatarX + 3, avatarY + 3, avatarSize - 6, avatarSize - 6, 70, GREEN, true); text(utf8Prefix(username, 24), 324, 174, 54, GREEN, true); const std::string bio = galleryProfile.bio.empty() ? "No bio added" : galleryProfile.bio; text(utf8Prefix(bio, 58), 326, 247, 25, galleryProfile.bio.empty() ? GRAY : INK); rounded(128, 350, 1024, 4, 2, GREEN); auto statCard = [&](int x, int y, const std::string& value, const std::string& label) { rounded(x + 2, y + 4, 392, 82, 10, SDL_Color{0, 0, 0, 30}); rounded(x, y, 392, 82, 10, GREEN); rounded(x + 2, y + 2, 388, 78, 8, WHITE); centered(value, x + 10, y + 3, 372, 48, 37, GREEN, true); centered(label, x + 10, y + 47, 372, 28, 16, PALE_GREEN, true); }; statCard(182, 374, std::to_string(galleryProfile.switchnotes) + " " + brandString("Switchnotes"), "Uploaded to " + appDisplayName + " Gallery"); statCard(706, 374, std::to_string(galleryProfile.views) + " Views", "Total on all uploaded " + brandString("Switchnotes")); statCard(182, 474, std::to_string(galleryProfile.coins) + " Coins", "Added across all uploaded " + brandString("Switchnotes")); statCard(706, 474, std::to_string(galleryProfile.followers) + " Followers", "Following this user on Gallery"); } else { if (gallery.empty()) { rounded(330, 226, 620, 258, 20, SDL_Color{0, 0, 0, 28}); rounded(330, 218, 620, 258, 20, WHITE); centered(galleryLoadRunning ? "Loading uploads…" : "No uploads on this page", 370, 320, 540, 42, 25, INK, true); } for (int cell = 0; cell < 6 && cell < static_cast(gallery.size()); ++cell) { const int col = cell % 3, row = cell / 3; const int x = 104 + col * 372, y = 153 + row * 232; const bool selectedNow = static_cast(cell) == gallerySelected; const int lift = hoverLift(x, y, pointerX >= x - 5 && pointerX < x + 326 && pointerY >= y - 4 && pointerY < y + 194); rounded(x - 5, y + 4 - lift, 331, 198, 15, SDL_Color{0, 0, 0, 48}); rounded(x - 5, y - 4 - lift, 331, 198, 15, WHITE); rounded(x + 3, y + 4 - lift, 315, 182, 8, WHITE); drawTexture(galleryPreviewTexture(gallery[static_cast(cell)]), x + 3, y + 4 - lift, 315, 182); roundedOutline(x - 1, y - lift, 323, 190, 14, 4, selectedNow ? GREEN : SDL_Color{210, 214, 220, 255}); if (gallery[static_cast(cell)].locked) drawLockIcon(x + 10, y + 10 - lift); rounded(x + 228, y + 158 - lift, 94, 30, 15, SDL_Color{255, 237, 190, 245}); drawImage("ui/icons/Coin.png", x + 236, y + 162 - lift, 22, 22); text(std::to_string(gallery[static_cast(cell)].coins), x + 265, y + 162 - lift, 17, ORANGE); if (selectedNow) { rounded(x + 137, y + 163 - lift, 59, 59, 30, SDL_Color{0, 0, 0, 42}); rounded(x + 133, y + 157 - lift, 59, 59, 30, WHITE); rounded(x + 138, y + 162 - lift, 49, 49, 25, PALE_GREEN); drawPlayTriangle(x + 153, y + 174 - lift, 21, WHITE); } if (hit(x - 5, y - 4, 331, 198)) { clickConsumed = true; if (selectedNow) { galleryProfileDetailMode = true; screen = Screen::GalleryDetails; } else { gallerySelected = static_cast(cell); playCue("button_on"); } } } if (gallery.size() > 0 && actionPill("ui/icons/ButtonAGalleryText.png", "Details", 1100, 635, 138, 50, true)) { galleryProfileDetailMode = true; screen = Screen::GalleryDetails; } } const int pageCount = 1 + (std::max(galleryProfile.switchnotes, galleryTotal) + 5) / 6; if (pageCount > 1) { const bool hasPrevious = galleryProfilePage > 0; const bool hasNext = galleryProfilePage + 1 < pageCount; if (pill("<", 24, 326, 60, 78, false, 34, hasPrevious) && hasPrevious) loadGalleryProfile(galleryProfile.identity, galleryProfileReturnScreen, galleryProfilePage - 1); if (pill(">", 1196, 326, 60, 78, true, 34, hasNext) && hasNext) loadGalleryProfile(galleryProfile.identity, galleryProfileReturnScreen, galleryProfilePage + 1); } if (imageButton("ui/BackButton_Gallery.png", 39, 620, 65, 65, "button_off")) leaveGalleryProfile(); const int dotCount = std::max(1, std::min(10, pageCount)); for (int i = 0; i < dotCount; ++i) { const int pageForDot = dotCount == pageCount ? i : i * (pageCount - 1) / std::max(1, dotCount - 1); rounded(640 - dotCount * 13 + i * 26, 655, 18, 18, 9, pageForDot == galleryProfilePage ? GREEN : PALE_GREEN); } } void App::renderDetails(bool galleryMode) { background(); if (galleryMode && gallery.empty()) { if (imageButton("ui/BackButton_Gallery.png", 39, 620, 65, 65, "button_off")) screen = Screen::Gallery; return; } const Item* item = nullptr; bool allowSpinoffs = false; if (galleryMode) item = &gallery[gallerySelected % gallery.size()]; else if (!library.empty()) item = &library[selected % library.size()]; if (!item) { if (pill("Back", 40, 620, 100, 50, galleryMode)) screen = galleryMode ? Screen::Gallery : Screen::Library; return; } const bool noteLocked = !galleryMode && ((docLoaded && docPath == item->path) ? doc.metadata.locked : item->locked); const int ix = 108, iy = 260, iw = 338, ih = 197; rounded(ix - 4, iy - 4, iw + 8, ih + 8, 14, WHITE); rounded(ix, iy, iw, ih, 10, ORANGE); rounded(ix + 4, iy + 4, iw - 8, ih - 8, 7, WHITE); if (galleryMode) drawTexture(galleryPreviewTexture(*item), ix + 4, iy + 4, iw - 8, ih - 8); else if (item->sample) drawImage("art/" + item->artwork, ix + 4, iy + 4, iw - 8, ih - 8, true); else if (docLoaded && docPath == item->path) drawCurrentFrame(ix + 4, iy + 4, iw - 8, ih - 8); else drawTexture(thumbTexture(*item), ix + 4, iy + 4, iw - 8, ih - 8); if (galleryMode) { if (item->locked) drawLockIcon(ix + 10, iy + 10); rounded(508, 181, 665, 67, 13, SDL_Color{226, 230, 242, 255}); text(utf8Prefix(item->title, 38), 525, 197, 28, INK, true); rounded(508, 262, 665, 110, 13, SDL_Color{226, 230, 242, 255}); text(item->description.empty() ? "No description added" : utf8Prefix(item->description, 48), 527, 284, 24, SDL_Color{112, 120, 141, 255}); rounded(508, 385, 665, 154, 13, SDL_Color{255, 234, 213, 255}); text("Author", 526, 402, 24, ORANGE); const bool isCurrentAuthor = item->ownerIdentity.empty() ? item->author == userName : item->ownerIdentity == sppm::identityFingerprint(signingIdentity.publicIdentity); if (isCurrentAuthor && profileDetailTexture) drawTexture(profileDetailTexture, 932, 397, 32, 32); text(utf8Prefix(item->author, 24), isCurrentAuthor && profileDetailTexture ? 975 : 930, 402, 24, ORANGE); if (hit(925, 392, 235, 42) && !item->ownerIdentity.empty() && !galleryProfileDetailMode) { clickConsumed = true; loadGalleryProfile(item->ownerIdentity, Screen::GalleryDetails); } text("Creator FSID", 526, 441, 24, ORANGE); text(item->fsid, 930, 441, 24, ORANGE); text("Updated", 526, 480, 24, ORANGE); text(dateText(item->modifiedUnixSeconds), 810, 480, 24, ORANGE); text("Coins " + std::to_string(item->coins) + " Views " + std::to_string(item->views), 526, 554, 20, GREEN); } else { const bool isCurrentDocument = docLoaded && docPath == item->path; if (isCurrentDocument ? doc.metadata.locked : item->locked) drawLockIcon(1127, 201); allowSpinoffs = isCurrentDocument ? doc.metadata.allowSpinoffs : item->allowSpinoffs; rounded(508, 183, 665, 67, 13, SDL_Color{226, 230, 242, 255}); text(item->title, 524, 197, 27, INK, true); if (!noteLocked) drawImage("ui/icons/Edit.png", 1114, 195, 47, 47); rounded(508, 262, 665, 110, 13, SDL_Color{226, 230, 242, 255}); const std::string description = isCurrentDocument && !doc.metadata.description.empty() ? doc.metadata.description : !item->description.empty() ? item->description : "No description added"; text(utf8Prefix(description, 42), 526, 284, 24, SDL_Color{112, 120, 141, 255}); if (!noteLocked) drawImage("ui/icons/Edit.png", 1114, 292, 47, 47); const std::string authorName = isCurrentDocument ? doc.metadata.authorName : item->author; const std::string originalAuthorName = isCurrentDocument ? doc.metadata.originalAuthorName : (item->originalAuthor.empty() ? item->author : item->originalAuthor); const std::string authorFsid = isCurrentDocument ? doc.metadata.authorFsid : item->authorFsid; const std::string originalAuthorFsid = isCurrentDocument ? doc.metadata.originalAuthorFsid : item->originalAuthorFsid; const std::uint64_t modifiedAt = isCurrentDocument ? doc.metadata.modifiedUnixSeconds : item->modifiedUnixSeconds; const std::uint64_t createdAt = isCurrentDocument ? doc.metadata.createdUnixSeconds : item->createdUnixSeconds; centered(expandMetadata ? "Less Details" : "More Details", 540, 326, 590, 35, 23, SDL_Color{112, 120, 141, 255}); rounded(508, 385, 665, expandMetadata ? 220 : 154, 13, SDL_Color{255, 234, 213, 255}); if (expandMetadata) { text("Author FSID", 526, 397, 19, ORANGE); text(authorFsid.empty() ? "Unknown" : authorFsid, 820, 397, 19, ORANGE); text("Original Author FSID", 526, 427, 19, ORANGE); text(originalAuthorFsid.empty() ? "Unknown" : originalAuthorFsid, 820, 427, 19, ORANGE); text("Last Edited", 526, 457, 19, ORANGE); text(dateText(modifiedAt), 820, 457, 19, ORANGE); text("First Created", 526, 487, 19, ORANGE); text(dateText(createdAt), 820, 487, 19, ORANGE); text("Switchnote FSID", 526, 527, 17, GREEN, true); text(isCurrentDocument ? doc.metadata.currentId : item->currentId, 810, 527, 17, GREEN, true); text("Root FSID", 526, 553, 17, GREEN, true); text(isCurrentDocument ? doc.metadata.rootId : item->rootId, 810, 553, 17, GREEN, true); } else { text("Author", 526, 402, 24, ORANGE); const bool isCurrentAuthor = item->ownerIdentity.empty() ? authorName == userName : item->ownerIdentity == sppm::identityFingerprint(signingIdentity.publicIdentity); if (isCurrentAuthor && profileDetailTexture) drawTexture(profileDetailTexture, 932, 397, 32, 32); text(authorName, isCurrentAuthor && profileDetailTexture ? 975 : 977, 402, 24, ORANGE); text("Original Author", 526, 441, 24, ORANGE); text(originalAuthorName, 977, 441, 24, ORANGE); text("Last Edited", 526, 480, 24, ORANGE); text(dateText(modifiedAt), 800, 480, 24, ORANGE); } } if (imageButton(galleryMode ? "ui/BackButton_Gallery.png" : "ui/BackButton.png", 39, 620, 65, 65, "button_off")) { if (galleryMode && galleryProfileDetailMode) { galleryProfileDetailMode = false; screen = Screen::GalleryProfile; } else screen = galleryMode ? Screen::Gallery : Screen::Library; } if (galleryMode) { const bool galleryOwner = item->ownerIdentity == sppm::identityFingerprint(signingIdentity.publicIdentity); if (galleryOwner && dangerPill("Delete", 40, 36, 156, 50, 18)) { modalSelection = 0; modal = Modal::ConfirmGalleryDelete; } if (actionPill("ui/icons/ButtonYGalleryText.png", "Comments", 1040, 36, 198, 50, true)) openGalleryComments(); if (actionPill("ui/icons/Coin.png", "Add Coin", 796, 635, 174, 50, true)) addGalleryCoin(); if (pill("Save", 978, 635, 100, 50, true, 19)) saveGalleryItem(gallerySelected); if (actionPill("ui/icons/ButtonAGalleryText.png", "Watch", 1088, 635, 150, 50, true)) openGalleryItem(gallerySelected); } else { if (pill("Upload to Gallery", 1022, 36, 216, 50, true, 17, !item->sample)) publishGalleryItem(selected); if (actionPill("ui/icons/ButtonZLText.png", "Delete", 40, 36, 174, 50, false)) { modalSelection = 0; modal = Modal::ConfirmDelete; } if (actionPill("ui/icons/ButtonYText.png", "Edit", 980, 635, 100, 50, false, !noteLocked)) openItem(selected, true); if (actionPill("ui/icons/ButtonAText.png", "Watch", 1088, 635, 150, 50, false)) { openItem(selected, false); screen = Screen::Viewer; } if (pill("Spin-off", 798, 635, 174, 50, false, 18, allowSpinoffs && !noteLocked)) { openItem(selected, false); forkDocument(); } if (pill("Export", 580, 635, 174, 50, false, 19, !item->sample)) { modalSelection = 0; modal = Modal::Export; } } if (!galleryMode && !noteLocked && hit(1114, 195, 47, 47)) { clickConsumed = true; if (item->sample || !docLoaded || docPath != item->path) openItem(selected, true); beginText(Field::Title, docLoaded ? doc.metadata.title : item->title); } if (!galleryMode && !noteLocked && hit(1114, 292, 47, 47)) { clickConsumed = true; if (item->sample || !docLoaded || docPath != item->path) openItem(selected, true); beginText(Field::Description, docLoaded ? doc.metadata.description : ""); } if (!galleryMode && hit(508, 262, 665, 110)) { clickConsumed = true; expandMetadata = !expandMetadata; } if (hit(ix - 4, iy - 4, iw + 8, ih + 8) && !galleryMode) { clickConsumed = true; openItem(selected, false); screen = Screen::Viewer; } } bool App::loadSample(const Item& item, sppm::Document& out) { SDL_Surface* input = IMG_Load(join(assetsRoot, "art/" + item.artwork).c_str()); if (!input) return false; SDL_Surface* rgba = SDL_ConvertSurfaceFormat(input, SDL_PIXELFORMAT_RGBA32, 0); SDL_FreeSurface(input); if (!rgba) return false; if (rgba->w != sppm::kWidth || rgba->h != sppm::kHeight) { SDL_FreeSurface(rgba); return false; } out = sppm::Document{}; out.metadata.title = item.title; out.metadata.description = "Sample animation supplied with Fakenote Studio."; out.metadata.authorName = item.author; out.metadata.originalAuthorName = item.author; out.metadata.lastEditorName = item.author; out.metadata.authorFsid = newId("sample-creator"); out.metadata.currentId = newId("sample-note"); if (out.metadata.authorFsid.empty() || out.metadata.currentId.empty()) { SDL_FreeSurface(rgba); return false; } out.metadata.originalAuthorFsid = out.metadata.authorFsid; out.metadata.lastEditorFsid = out.metadata.authorFsid; out.metadata.rootId = out.metadata.currentId; out.metadata.createdUnixSeconds = out.metadata.modifiedUnixSeconds = nowSeconds(); out.metadata.fps = 12; out.metadata.speed = 6; out.frames.emplace_back(); std::unordered_map exact; std::map frequency; for (int y = 0; y < rgba->h; ++y) { const auto* row = reinterpret_cast(static_cast(rgba->pixels) + y * rgba->pitch); for (int x = 0; x < rgba->w; ++x) { const std::uint32_t p = row[x]; std::uint8_t r, g, b, a; SDL_GetRGBA(p, rgba->format, &r, &g, &b, &a); if (a >= 16) ++frequency[(static_cast(a) << 24) | (static_cast(r) << 16) | (static_cast(g) << 8) | b]; } } std::vector> colors(frequency.begin(), frequency.end()); std::sort(colors.begin(), colors.end(), [](const auto& a, const auto& b) { return a.second > b.second; }); if (colors.size() > 255) colors.resize(255); for (std::size_t i = 0; i < colors.size(); ++i) { const auto key = colors[i].first; out.palette[i + 1] = {static_cast(key >> 16), static_cast(key >> 8), static_cast(key), static_cast(key >> 24)}; exact[key] = static_cast(i + 1); } auto& pixels = out.frames[0].layers[3]; for (int y = 0; y < rgba->h; ++y) { const auto* row = reinterpret_cast(static_cast(rgba->pixels) + y * rgba->pitch); for (int x = 0; x < rgba->w; ++x) { std::uint8_t r, g, b, a; SDL_GetRGBA(row[x], rgba->format, &r, &g, &b, &a); if (a < 16) continue; const std::uint32_t key = (static_cast(a) << 24) | (static_cast(r) << 16) | (static_cast(g) << 8) | b; auto found = exact.find(key); if (found != exact.end()) pixels[static_cast(y) * rgba->w + x] = found->second; else { int bestDistance = std::numeric_limits::max(); std::uint8_t best = 1; for (std::size_t i = 1; i <= colors.size(); ++i) { const auto& c = out.palette[i]; int dr = static_cast(r) - c.r, dg = static_cast(g) - c.g, db = static_cast(b) - c.b; const int d = dr * dr + dg * dg + db * db; if (d < bestDistance) { bestDistance = d; best = static_cast(i); } } pixels[static_cast(y) * rgba->w + x] = best; } } } SDL_FreeSurface(rgba); return true; } void App::openItem(std::size_t index, bool edit) { LoadingScope loading(*this); if (index >= library.size()) return; if (docLoaded && dirty) saveDocument(); const Item item = library[index]; if (edit && item.locked) { showToast("This Switchnote is locked and cannot be edited"); return; } stopPlayback(); if (edit) { color = 2; activeTool = Tool::Pen; paintMenu = colorMenu = false; toolMenu = false; brush = 4; pattern = 0; square = false; layer = 0; } if (item.sample) { sppm::Document sample; if (!loadSample(item, sample)) { showToast("Could not load sample drawing"); return; } if (edit) { sppm::Document child; const std::string childId = newId("spinoff"); if (childId.empty()) { showToast("Secure random ID generation failed"); return; } std::string forkError; if (!sppm::createSpinoff(sample, userName, myFsid, childId, nowSeconds(), child, forkError)) { showToast("Could not create sample spin-off: " + forkError); return; } child.metadata.authorIdentity = signingIdentity.publicIdentity; child.metadata.lastEditorIdentity = signingIdentity.publicIdentity; child.metadata.title = item.title + " (spin-off)"; doc = std::move(child); color = blackInkIndex(doc.palette); docPath.clear(); docLoaded = true; dirty = false; galleryViewing = false; frame = 0; screen = Screen::Canvas; invalidateFrames(); rebuildAudio(); showToast("Editing an included sample; save it as a spin-off"); } else { doc = std::move(sample); docPath.clear(); docLoaded = true; galleryViewing = false; frame = 0; screen = Screen::Viewer; invalidateFrames(); rebuildAudio(); } return; } std::string error; if (!sppm::loadSignedFile(item.path, doc, signingIdentity.legacyHmacKey, error)) { showToast("Could not open Switchnote: " + error); return; } docPath = item.path; if (edit && doc.metadata.locked) { docLoaded = false; docPath.clear(); showToast("This Switchnote is locked and cannot be edited"); return; } docLoaded = true; galleryViewing = false; dirty = false; frame = 0; if (edit) color = blackInkIndex(doc.palette); screen = edit ? Screen::Canvas : Screen::Viewer; invalidateFrames(); rebuildAudio(); } void App::newDocument() { if (docLoaded && dirty) saveDocument(); const std::string id = newId("note"); if (id.empty()) { showToast("Secure random ID generation failed"); return; } stopPlayback(); doc = sppm::Document{}; rebuildAudio(); doc.frames.emplace_back(); doc.metadata.title = defaultSwitchnoteTitle(userName, noteSingular); doc.metadata.authorName = userName; doc.metadata.originalAuthorName = userName; doc.metadata.lastEditorName = userName; doc.metadata.authorFsid = myFsid; doc.metadata.originalAuthorFsid = myFsid; doc.metadata.lastEditorFsid = myFsid; doc.metadata.authorIdentity = signingIdentity.publicIdentity; doc.metadata.originalAuthorIdentity = signingIdentity.publicIdentity; doc.metadata.lastEditorIdentity = signingIdentity.publicIdentity; doc.metadata.currentId = id; doc.metadata.rootId = doc.metadata.currentId; doc.metadata.createdUnixSeconds = doc.metadata.modifiedUnixSeconds = nowSeconds(); doc.metadata.fps = static_cast(defaultFps); doc.metadata.speed = static_cast(defaultSpeed); docPath.clear(); docLoaded = true; dirty = true; frame = 0; layer = 0; color = blackInkIndex(doc.palette); brush = 4; pattern = 0; square = false; activeTool = Tool::Pen; paintMenu = false; colorMenu = toolMenu = false; zoom = 1.0f; panX = panY = 0; undoStack.clear(); redoStack.clear(); invalidateFrames(); saveDocument(); for (std::size_t i = 0; i < library.size(); ++i) { if (library[i].path == docPath) { selected = i; page = static_cast(i / 6); break; } } screen = Screen::Canvas; showToast("New Switchnote — draw now; use New to add frames"); playEvent("note_new", "page_new"); } bool App::saveDocument(bool force) { LoadingScope loading(*this); if (!docLoaded) return false; if (doc.metadata.locked && dirty) { showToast("This Switchnote is locked and cannot be edited"); return false; } if (!dirty && !force) return true; if (docPath.empty()) { // A sample spin-off receives its persistent file only when explicitly saved. doc.metadata.title = doc.metadata.title.empty() ? defaultSwitchnoteTitle(userName, noteSingular) : doc.metadata.title; docPath = join(join(notesRoot, folder), doc.metadata.currentId + ".sppm"); } doc.metadata.lastEditorName = userName; doc.metadata.lastEditorFsid = myFsid; doc.metadata.lastEditorIdentity = signingIdentity.publicIdentity; doc.metadata.modifiedUnixSeconds = nowSeconds(); std::string error; if (sppm::savePublicSignedFile(docPath, doc, signingIdentity, error)) { const std::string thumbnailKey = "thumb:" + docPath; auto thumbnail = images.find(thumbnailKey); if (thumbnail != images.end()) { if (thumbnail->second) SDL_DestroyTexture(thumbnail->second); images.erase(thumbnail); } scanLibrary(); bool foundSavedItem = false; for (std::size_t i = 0; i < library.size(); ++i) { if (library[i].path == docPath) { selected = i; page = static_cast(i / 6); foundSavedItem = true; break; } } if (!foundSavedItem) { sppm::Document persisted; std::string verifyError; if (!sppm::loadSignedFile(docPath, persisted, signingIdentity.legacyHmacKey, verifyError)) { showToast("Saved Switchnote could not be reopened: " + verifyError); return false; } Item saved{}; saved.title = persisted.metadata.title.empty() ? defaultSwitchnoteTitle(userName, noteSingular) : persisted.metadata.title; saved.path = docPath; saved.author = persisted.metadata.authorName; saved.description = persisted.metadata.description; saved.originalAuthor = persisted.metadata.originalAuthorName; saved.authorFsid = persisted.metadata.authorFsid; saved.originalAuthorFsid = persisted.metadata.originalAuthorFsid; saved.ownerIdentity = sppm::identityFingerprint(persisted.metadata.authorIdentity); saved.currentId = persisted.metadata.currentId; saved.rootId = persisted.metadata.rootId; saved.createdUnixSeconds = persisted.metadata.createdUnixSeconds; saved.modifiedUnixSeconds = persisted.metadata.modifiedUnixSeconds; saved.allowSpinoffs = persisted.metadata.allowSpinoffs; library.push_back(std::move(saved)); std::sort(library.begin(), library.end(), [](const Item& a, const Item& b) { if (a.modifiedUnixSeconds != b.modifiedUnixSeconds) return a.modifiedUnixSeconds > b.modifiedUnixSeconds; if (a.createdUnixSeconds != b.createdUnixSeconds) return a.createdUnixSeconds > b.createdUnixSeconds; return a.path < b.path; }); for (std::size_t i = 0; i < library.size(); ++i) { if (library[i].path == docPath) { selected = i; page = static_cast(i / 6); foundSavedItem = true; break; } } } if (!foundSavedItem) { showToast("Switchnote saved, but could not be added to the library"); return false; } dirty = false; return true; } showToast("Save failed: " + error); return false; } void App::closeDocument() { saveDocument(); stopPlayback(); docLoaded = false; docPath.clear(); frame = 0; doc = sppm::Document{}; rebuildAudio(); invalidateFrames(); } void App::deleteDocument() { if (library.empty() || selected >= library.size()) return; const Item item = library[selected]; if (item.sample) { showToast("Bundled sample artwork cannot be deleted"); return; } if (!item.path.empty() && std::remove(item.path.c_str()) != 0) { showToast("Could not delete this Switchnote"); return; } if (docPath == item.path) { stopPlayback(); docLoaded = false; dirty = false; docPath.clear(); doc = sppm::Document{}; rebuildAudio(); invalidateFrames(); } scanLibrary(); screen = Screen::Library; modal = Modal::None; showToast("Switchnote deleted"); playEvent("delete", "delete_complete"); } void App::forkDocument() { if (!docLoaded && !library.empty()) openItem(selected, false); if (!docLoaded) return; const std::string id = newId("spinoff"); if (id.empty()) { showToast("Secure random ID generation failed"); return; } sppm::Document child; std::string error; if (!sppm::createSpinoff(doc, userName, myFsid, id, nowSeconds(), child, error)) { showToast("Spin-off unavailable: " + error); return; } child.metadata.authorIdentity = signingIdentity.publicIdentity; child.metadata.lastEditorIdentity = signingIdentity.publicIdentity; stopPlayback(); doc = std::move(child); color = blackInkIndex(doc.palette); rebuildAudio(); doc.metadata.title = doc.metadata.title + " (spin-off)"; docPath.clear(); galleryViewing = false; dirty = true; frame = std::min(frame, doc.frames.size() - 1); screen = Screen::Canvas; saveDocument(); scanLibrary(); showToast("Spin-off created with the original author and lineage preserved"); } void App::markDirty() { dirty = true; dirtyTick = SDL_GetTicks(); ++docRevision; invalidateFrames(); } void App::invalidateFrames() { for (auto& item : frameTextures) if (item.second.texture) SDL_DestroyTexture(item.second.texture); frameTextures.clear(); ++docRevision; } SDL_Texture* App::frameTexture(std::size_t index) { if (!docLoaded || index >= doc.frames.size()) return nullptr; auto& cache = frameTextures[index]; if (cache.texture && cache.revision == docRevision) return cache.texture; if (cache.texture) SDL_DestroyTexture(cache.texture); SDL_Surface* surface = SDL_CreateRGBSurfaceWithFormat(0, sppm::kWidth, sppm::kHeight, 32, SDL_PIXELFORMAT_RGBA32); if (!surface) return nullptr; const auto& frameData = doc.frames[index]; auto* pixels = static_cast(surface->pixels); for (std::size_t i = 0; i < sppm::kPixelsPerLayer; ++i) { std::uint32_t out = 0; for (std::size_t l = sppm::kLayerCount; l-- > 0;) { const std::uint8_t idx = frameData.layers[l][i]; if (!idx) continue; const auto& c = doc.palette[idx]; if (!c.a) continue; const std::uint8_t alpha = c.a; std::uint8_t r, g, b, oldA; SDL_GetRGBA(out, surface->format, &r, &g, &b, &oldA); const int inv = 255 - alpha; const int nextA = alpha + (oldA * inv + 127) / 255; if (nextA == 0) { out = 0; continue; } r = static_cast((c.r * alpha + r * oldA * inv / 255 + nextA / 2) / nextA); g = static_cast((c.g * alpha + g * oldA * inv / 255 + nextA / 2) / nextA); b = static_cast((c.b * alpha + b * oldA * inv / 255 + nextA / 2) / nextA); out = SDL_MapRGBA(surface->format, r, g, b, static_cast(nextA)); } pixels[i] = out; } cache.texture = SDL_CreateTextureFromSurface(renderer, surface); SDL_FreeSurface(surface); if (cache.texture) { SDL_SetTextureScaleMode(cache.texture, SDL_ScaleModeNearest); SDL_SetTextureBlendMode(cache.texture, SDL_BLENDMODE_BLEND); } cache.revision = docRevision; return cache.texture; } void App::drawFrameImage(std::size_t index, int x, int y, int w, int h) { drawTexture(frameTexture(index), x, y, w, h); } void App::drawCurrentFrame(int x, int y, int w, int h, bool withOnion) { if (!docLoaded || doc.frames.empty()) return; if (withOnion && onion && frame > 0) { SDL_Texture* previous = frameTexture(frame - 1); if (previous) { SDL_SetTextureColorMod(previous, 255, 96, 104); SDL_SetTextureAlphaMod(previous, 55); drawTexture(previous, x, y, w, h); SDL_SetTextureColorMod(previous, 255, 255, 255); SDL_SetTextureAlphaMod(previous, 255); } } drawFrameImage(frame, x, y, w, h); } void App::playControl(int x, int y, int w, int h) { if (!playing) { if (imagePill("ui/icons/Play.png", x, y, w, h, false, true, "")) startPlayback(); return; } const bool hovered = pointerX >= x && pointerX < x + w && pointerY >= y && pointerY < y + h; const bool pressed = pointerDown && hovered; const int lift = hoverLift(x, y, hovered); rounded(x, y + 5 - lift + (pressed ? 4 : 0), w, h, h / 2, SDL_Color{0, 0, 0, 42}); rounded(x - 3, y - 3 - lift + (pressed ? 4 : 0), w + 6, h + 6, h / 2 + 3, WHITE); rounded(x, y - lift + (pressed ? 4 : 0), w, h, h / 2, CREAM); const int barW = std::max(5, w / 10), barH = std::max(18, h / 3); fill(x + w / 2 - barW - 3, y + (h - barH) / 2 - lift + (pressed ? 4 : 0), barW, barH, ORANGE); fill(x + w / 2 + 3, y + (h - barH) / 2 - lift + (pressed ? 4 : 0), barW, barH, ORANGE); if (hit(x, y, w, h)) { clickConsumed = true; stopPlayback(); } } void App::setFrame(std::size_t index, bool playNavigationCue, const char* event) { if (!docLoaded || doc.frames.empty()) return; const std::size_t previous = frame; frame = std::min(index, doc.frames.size() - 1); if (playNavigationCue && frame != previous) { if (event) playEvent(event, "button_on"); else playEvent(frame > previous ? "frame_next" : "frame_previous", "button_on"); } if (playing) playFrameSound(); } void App::startPlayback() { if (!docLoaded || doc.frames.empty()) return; // Play always means replay from the beginning. Reset audio before changing // the cursor so BGM and frame-zero cues share the animation's time origin. stopPlayback(false); frame = 0; playing = true; lastPlaybackTick = SDL_GetTicks(); playbackTickRemainder = 0; playEvent("playback_start", "movie_play"); playFrameSound(); } void App::updatePlayback(std::uint32_t now) { if (!playing || !docLoaded || doc.frames.empty()) return; const double exactFrameDuration = 1000.0 / sppm::frameRate(doc.metadata); // Advance against the original frame clock rather than resetting it to // `now`; if a render takes longer than one frame, catch up and keep the // displayed counter synchronized with the animation. while (playing) { const double accumulated = exactFrameDuration + playbackTickRemainder; const std::uint32_t frameDuration = std::max(1, static_cast(std::floor(accumulated))); if (now - lastPlaybackTick < frameDuration) break; playbackTickRemainder = accumulated - frameDuration; lastPlaybackTick += frameDuration; if (frame + 1 < doc.frames.size()) setFrame(frame + 1, false); else if (!doc.metadata.loopPlayback) stopPlayback(false); else { if (audioReady) for (int channel = 0; channel < 4; ++channel) Mix_HaltChannel(channel); setFrame(0, false); } } } void App::stopPlayback(bool playStopCue) { if (playing && playStopCue) playEvent("playback_stop", "movie_stop"); playing = false; if (audioReady) { for (int channel = 0; channel < 5; ++channel) Mix_HaltChannel(channel); } if (bgmSeekChunk) { Mix_FreeChunk(bgmSeekChunk); bgmSeekChunk = nullptr; } } void App::startBgmAtCurrentFrame() { if (!audioReady || !trackChunks[0]) return; Mix_HaltChannel(0); if (bgmSeekChunk) { Mix_FreeChunk(bgmSeekChunk); bgmSeekChunk = nullptr; } if (!doc.metadata.bgmFollowsSpeed) { Mix_PlayChannel(0, trackChunks[0], 0); return; } if (frame == 0) { Mix_PlayChannel(0, trackChunks[0], 0); return; } const auto& buffer = trackAudioBuffers[0]; const std::size_t sampleFrame = static_cast( std::floor(frame * 48000.0 / sppm::frameRate(doc.metadata))); const std::size_t offset = sampleFrame * 4; if (offset + 4 > buffer.size()) return; bgmSeekChunk = Mix_QuickLoad_RAW(const_cast(buffer.data() + offset), static_cast(buffer.size() - offset)); if (bgmSeekChunk) Mix_PlayChannel(0, bgmSeekChunk, 0); } void App::rebuildAudio() { if (!audioReady) return; Mix_HaltChannel(4); for (int channel = 0; channel < static_cast(trackChunks.size()); ++channel) Mix_HaltChannel(channel); if (bgmSeekChunk) { Mix_FreeChunk(bgmSeekChunk); bgmSeekChunk = nullptr; } for (auto* chunk : trackChunks) if (chunk) Mix_FreeChunk(chunk); trackChunks.fill(nullptr); for (auto& buffer : trackAudioBuffers) buffer.clear(); for (std::size_t i = 0; i < doc.audioTracks.size() && i < trackChunks.size(); ++i) { auto& track = doc.audioTracks[i]; if (track.pcm.empty()) continue; if (track.channels < 1 || track.channels > 2 || track.sampleRate < 8000 || track.sampleRate > 48000) continue; SDL_AudioCVT cvt{}; if (SDL_BuildAudioCVT(&cvt, AUDIO_S16SYS, track.channels, static_cast(track.sampleRate), AUDIO_S16SYS, 2, 48000) < 0) continue; const auto bytes = track.pcm.size() * sizeof(std::int16_t); if (bytes > static_cast(std::numeric_limits::max())) continue; cvt.len = static_cast(bytes); cvt.buf = static_cast(SDL_malloc(static_cast(cvt.len) * cvt.len_mult)); if (!cvt.buf) continue; std::memcpy(cvt.buf, track.pcm.data(), bytes); if (cvt.needed) { if (SDL_ConvertAudio(&cvt) != 0) { SDL_free(cvt.buf); continue; } } else { cvt.len_cvt = cvt.len; } if (cvt.len_cvt <= 0) { SDL_free(cvt.buf); continue; } if (i == 0 && doc.metadata.bgmFollowsSpeed && cvt.len_cvt >= 4) { const std::size_t inputFrames = static_cast(cvt.len_cvt) / 4; const double currentFps = sppm::frameRate(doc.metadata); const double referenceFps = track.referenceSpeed ? selectedFrameRate(track.referenceSpeed, 0) : (track.referenceFps ? track.referenceFps : currentFps); const double playbackRate = currentFps / referenceFps; constexpr std::size_t maxPlaybackSamples = 32ULL * 1024 * 1024; const std::size_t maxOutputFrames = maxPlaybackSamples / 2; const std::size_t requestedFrames = std::max( 1, static_cast(std::ceil(inputFrames / playbackRate))); const std::size_t outputFrames = std::min(requestedFrames, maxOutputFrames); trackAudioBuffers[i].resize(outputFrames * 4); const auto* sourceSamples = reinterpret_cast(cvt.buf); auto* outputSamples = reinterpret_cast(trackAudioBuffers[i].data()); for (std::size_t outFrame = 0; outFrame < outputFrames; ++outFrame) { const double sourcePosition = std::min(static_cast(inputFrames - 1), outFrame * playbackRate); const std::size_t sourceFrame = static_cast(sourcePosition); const std::size_t nextFrame = std::min(sourceFrame + 1, inputFrames - 1); const double fraction = sourcePosition - sourceFrame; for (int channel = 0; channel < 2; ++channel) { const double a = sourceSamples[sourceFrame * 2 + channel]; const double b = sourceSamples[nextFrame * 2 + channel]; outputSamples[outFrame * 2 + channel] = static_cast(std::clamp( static_cast(std::lround(a + (b - a) * fraction)), -32768, 32767)); } } } else { trackAudioBuffers[i].assign(cvt.buf, cvt.buf + cvt.len_cvt); } SDL_free(cvt.buf); trackChunks[i] = Mix_QuickLoad_RAW(trackAudioBuffers[i].data(), static_cast(trackAudioBuffers[i].size())); } if (playing) startBgmAtCurrentFrame(); } void App::playFrameSound() { if (!audioReady || !docLoaded || !playing) return; if (frame == 0 && trackChunks[0] && (doc.metadata.bgmFollowsSpeed || !Mix_Playing(0))) startBgmAtCurrentFrame(); for (std::size_t i = 1; i < doc.audioTracks.size() && i < trackChunks.size(); ++i) { const auto& cuesAt = doc.audioTracks[i].frameTriggers; if (std::find(cuesAt.begin(), cuesAt.end(), static_cast(frame)) != cuesAt.end() && trackChunks[i]) Mix_PlayChannel(static_cast(i), trackChunks[i], 0); } } void App::pushUndo() { if (!docLoaded || frame >= doc.frames.size() || layer < 0 || layer >= 4) return; if (fillWaveActive) { fillWaveActive = false; fillWavePixels.clear(); } undoStack.push_back({frame, layer, doc.frames[frame].layers[layer]}); if (undoStack.size() > 32) undoStack.erase(undoStack.begin()); redoStack.clear(); } void App::beginFillWave(int x, int y) { if (!docLoaded || frame >= doc.frames.size() || layer < 0 || layer >= 4 || x < 0 || x >= sppm::kWidth || y < 0 || y >= sppm::kHeight) return; auto& pixels = doc.frames[frame].layers[static_cast(layer)]; const std::size_t seed = static_cast(y) * sppm::kWidth + x; const std::uint8_t target = pixels[seed]; const std::uint8_t replacement = activeTool == Tool::Eraser ? 0 : static_cast(std::clamp(color, 1, 255)); if (target == replacement) return; pushUndo(); fillWavePixels.clear(); fillWavePixels.reserve(pixels.size() / 4); std::vector queue; queue.reserve(pixels.size() / 4); std::vector visited(pixels.size(), 0); std::vector distances(pixels.size(), std::numeric_limits::max()); queue.push_back(static_cast(seed)); visited[seed] = 1; distances[seed] = 0; for (std::size_t cursor = 0; cursor < queue.size(); ++cursor) { const std::uint32_t index = queue[cursor]; const int px = static_cast(index % sppm::kWidth); const int py = static_cast(index / sppm::kWidth); fillWavePixels.emplace_back(index, distances[index]); const std::array neighbors{{px > 0 ? static_cast(index) - 1 : -1, px + 1 < sppm::kWidth ? static_cast(index) + 1 : -1, py > 0 ? static_cast(index) - sppm::kWidth : -1, py + 1 < sppm::kHeight ? static_cast(index) + sppm::kWidth : -1}}; for (const int next : neighbors) { if (next < 0 || visited[static_cast(next)] || pixels[static_cast(next)] != target) continue; visited[static_cast(next)] = 1; distances[static_cast(next)] = static_cast(distances[index] + 1); queue.push_back(static_cast(next)); } } std::sort(fillWavePixels.begin(), fillWavePixels.end(), [](const auto& a, const auto& b) { return a.second < b.second; }); fillWaveFrame = frame; fillWaveLayer = layer; fillWaveColor = replacement; fillWaveOriginX = x; fillWaveOriginY = y; fillWaveRadius = 0.0f; fillWaveCursor = 0; fillWaveTick = SDL_GetTicks(); fillWaveActive = !fillWavePixels.empty(); } void App::updateFillWave(std::uint32_t now) { if (!fillWaveActive) return; if (!docLoaded || frame != fillWaveFrame || layer != fillWaveLayer || frame >= doc.frames.size()) { fillWaveActive = false; fillWavePixels.clear(); return; } const float elapsed = std::min(0.1f, (now - fillWaveTick) / 1000.0f); fillWaveTick = now; // 25 drawing pixels every 0.25 seconds (100 pixels/second), staying within // the connected region instead of crossing line-art boundaries. fillWaveRadius += elapsed * 100.0f; auto& pixels = doc.frames[frame].layers[static_cast(layer)]; bool changed = false; const auto radius = static_cast(std::floor(fillWaveRadius)); while (fillWaveCursor < fillWavePixels.size() && fillWavePixels[fillWaveCursor].second <= radius) { pixels[fillWavePixels[fillWaveCursor].first] = fillWaveColor; ++fillWaveCursor; changed = true; } if (changed) markDirty(); if (fillWaveCursor >= fillWavePixels.size()) { fillWaveActive = false; fillWavePixels.clear(); } } void App::undo() { if (undoStack.empty() || !docLoaded) return; if (fillWaveActive) { fillWaveActive = false; fillWavePixels.clear(); } UndoState state = std::move(undoStack.back()); undoStack.pop_back(); redoStack.push_back({state.frame, state.layer, doc.frames[state.frame].layers[state.layer]}); doc.frames[state.frame].layers[state.layer] = std::move(state.pixels); frame = state.frame; layer = state.layer; markDirty(); playEvent("undo", "undo"); } void App::redo() { if (redoStack.empty() || !docLoaded) return; if (fillWaveActive) { fillWaveActive = false; fillWavePixels.clear(); } UndoState state = std::move(redoStack.back()); redoStack.pop_back(); undoStack.push_back({state.frame, state.layer, doc.frames[state.frame].layers[state.layer]}); doc.frames[state.frame].layers[state.layer] = std::move(state.pixels); frame = state.frame; layer = state.layer; markDirty(); playEvent("redo", "redo"); } void App::insertFrame(bool paste) { if (!docLoaded || doc.frames.size() >= sppm::kMaxFrames) { showToast("This Switchnote has reached its 9999 frame limit"); return; } const std::size_t insertAt = std::min(frame + 1, doc.frames.size()); sppm::Frame next; if (paste && clipboardReady) next = frameClipboard; else if (copyToNewPage && frame < doc.frames.size()) next = doc.frames[frame]; doc.frames.insert(doc.frames.begin() + static_cast(insertAt), std::move(next)); for (auto& track : doc.audioTracks) for (auto& cue : track.frameTriggers) if (cue >= insertAt) ++cue; frame = insertAt; markDirty(); playEvent(paste ? "frame_paste" : "frame_new", paste ? "pic_stamp" : "page_new"); } void App::eraseFrame() { if (!docLoaded || doc.frames.size() <= 1) { showToast("A Switchnote must keep at least one frame"); return; } doc.frames.erase(doc.frames.begin() + static_cast(frame)); for (auto& track : doc.audioTracks) { track.frameTriggers.erase(std::remove(track.frameTriggers.begin(), track.frameTriggers.end(), static_cast(frame)), track.frameTriggers.end()); for (auto& cue : track.frameTriggers) if (cue > frame) --cue; } frame = std::min(frame, doc.frames.size() - 1); markDirty(); playEvent("frame_erase", "draw_tool_btn_on"); } void App::copyFrame() { if (!docLoaded || frame >= doc.frames.size()) return; frameClipboard = doc.frames[frame]; clipboardReady = true; playEvent("frame_copy", "pic_stamp"); showToast("Frame copied"); } SDL_Texture* App::thumbTexture(const Item& item) { const std::string key = "thumb:" + item.path; auto cached = images.find(key); if (cached != images.end()) return cached->second; sppm::Document source; std::string error; if (!sppm::loadSignedFile(item.path, source, signingIdentity.legacyHmacKey, error) || source.frames.empty()) return nullptr; SDL_Surface* surface = SDL_CreateRGBSurfaceWithFormat(0, sppm::kWidth, sppm::kHeight, 32, SDL_PIXELFORMAT_RGBA32); if (!surface) return nullptr; auto* out = static_cast(surface->pixels); const auto& frameData = source.frames[std::min(source.metadata.coverFrame, source.frames.size() - 1)]; for (std::size_t i = 0; i < sppm::kPixelsPerLayer; ++i) { unsigned r = 0, g = 0, b = 0, a = 0; for (std::size_t layerIndex = frameData.layers.size(); layerIndex-- > 0;) { const auto& layerPixels = frameData.layers[layerIndex]; const auto index = layerPixels[i]; if (!index) continue; const auto c = source.palette[index]; const unsigned na = c.a + a * (255 - c.a) / 255; if (na) { r = (c.r * c.a + r * a * (255 - c.a) / 255) / na; g = (c.g * c.a + g * a * (255 - c.a) / 255) / na; b = (c.b * c.a + b * a * (255 - c.a) / 255) / na; } a = na; } out[i] = SDL_MapRGBA(surface->format, r, g, b, a); } SDL_Texture* result = SDL_CreateTextureFromSurface(renderer, surface); SDL_FreeSurface(surface); if (result) { SDL_SetTextureScaleMode(result, SDL_ScaleModeNearest); SDL_SetTextureBlendMode(result, SDL_BLENDMODE_BLEND); } images[key] = result; return result; } void App::renderSequence() { background(); if (!docLoaded) { if (pill("Back", 40, 620, 100, 48)) screen = Screen::Library; return; } const bool canUndo = !undoStack.empty(); const bool canRedo = !redoStack.empty(); drawImage(canUndo ? "ui/CanvasUndoButton_Active.png" : "ui/CanvasUndoButton_Inactive.png", 20, 18, 68, 54); drawImage(canRedo ? "ui/CanvasRedoButton_Active.png" : "ui/CanvasRedoButton_Inactive.png", 96, 18, 68, 54); if (canUndo && hit(20, 18, 68, 54)) { clickConsumed = true; undo(); } if (canRedo && hit(96, 18, 68, 54)) { clickConsumed = true; redo(); } rounded(321, 36, 638, 362, 14, WHITE); rounded(325, 40, 630, 354, 10, SDL_Color{210, 214, 220, 255}); rounded(329, 44, 622, 346, 8, WHITE); drawCurrentFrame(331, 46, 618, 342, true); const std::array tools{{"New", "Erase", "Copy", "Paste"}}; const std::array icons{{"FrameNew.png", "FrameErase.png", "FrameCopy.png", "FramePaste.png"}}; for (int i = 0; i < 4; ++i) { const int y = 22 + i * 66; if (actionPill("ui/icons/" + icons[i], tools[i], 1090, y, 166, 45, false, i != 3 || clipboardReady, "")) { if (i == 0) insertFrame(false); else if (i == 1) eraseFrame(); else if (i == 2) copyFrame(); else insertFrame(true); } } rounded(1110, 366, 146, 40, 20, SDL_Color{0, 0, 0, 38}); rounded(1110, 361, 146, 40, 20, WHITE); centered((frame + 1 < 10 ? "000" : frame + 1 < 100 ? "00" : "0") + std::to_string(frame + 1) + " / " + (doc.frames.size() < 10 ? "000" : doc.frames.size() < 100 ? "00" : "0") + std::to_string(doc.frames.size()), 1110, 361, 146, 40, 18, ORANGE, true); fill(0, 420, W, 196, SDL_Color{16, 16, 18, 255}); for (int x = 8; x < W; x += 28) { rounded(x, 436, 14, 14, 3, WHITE); rounded(x, 591, 14, 14, 3, WHITE); } const int visibleFrames = std::min(6, static_cast(doc.frames.size())); const int gap = 14; constexpr int cardW = 174; constexpr int cardH = 112; const int firstFrame = std::clamp(static_cast(frame) - 2, 0, std::max(0, static_cast(doc.frames.size()) - visibleFrames)); const int stripWidth = visibleFrames * cardW + std::max(0, visibleFrames - 1) * gap; const int firstX = (W - stripWidth) / 2; const int thumbY = 464; for (int visible = 0; visible < visibleFrames; ++visible) { const int idx = firstFrame + visible; const int x = firstX + visible * (cardW + gap); const int y = thumbY; if (static_cast(idx) == frame) rounded(x - 5, y - 5, cardW + 10, cardH + 10, 14, ORANGE); rounded(x, y, cardW, cardH, 10, WHITE); drawFrameImage(static_cast(idx), x + 4, y + 4, cardW - 8, cardH - 8); if (hit(x, y, cardW, cardH)) { setFrame(static_cast(idx)); clickConsumed = true; } } if (imageButton("ui/BackButton.png", 20, 635, 62, 62, "button_off")) screen = Screen::Canvas; drawImage("ui/SeekBar.png", 371, 649, 540, 30); const int seekX = 373 + (doc.frames.size() > 1 ? static_cast(frame * 535 / (doc.frames.size() - 1)) : 0); drawImage("ui/SeekBarHandle.png", seekX - 8, 644, 16, 40); if (hit(370, 640, 540, 48)) { const float ratio = std::clamp((pointerX - 373.0f) / 535.0f, 0.0f, 1.0f); setFrame(static_cast(ratio * (doc.frames.size() - 1))); clickConsumed = true; } if (imagePill("ui/icons/SkipFirst.png", 913, 645, 55, 48, false, frame > 0, "")) setFrame(0, true, "frame_first"); if (imagePill("ui/icons/SkipPrev.png", 974, 645, 55, 48, false, frame > 0, "")) setFrame(frame > 5 ? frame - 5 : 0, true, "frame_previous"); playControl(1044, 635, 62, 62); if (imagePill("ui/icons/SkipNext.png", 1116, 645, 55, 48, false, true, "")) { if (frame + 1 < doc.frames.size()) setFrame(frame + 1); else insertFrame(false); } if (imagePill("ui/icons/SkipLast.png", 1177, 645, 58, 48, false, frame + 1 < doc.frames.size(), "")) setFrame(doc.frames.size() - 1, true, "frame_last"); } bool switchnoteBrushPattern(int id, int x, int y); bool switchnoteEraserContains(int slot, int dx, int dy); void App::renderCanvas() { if (!docLoaded) { screen = Screen::Library; return; } if (pointerReleased && !clickConsumed && (paintMenu || colorMenu || toolMenu)) { const bool insidePopup = pointerX >= 836 && pointerX < 1192 && pointerY >= 76 && pointerY < 362; const bool onTool = pointerX >= 1200 && pointerX < 1260 && pointerY >= 12 && pointerY < 72; const bool onBrushDot = pointerX >= 1200 && pointerX < 1260 && pointerY >= 80 && pointerY < 140; const bool onColorDot = pointerX >= 1200 && pointerX < 1260 && pointerY >= 147 && pointerY < 207; if (!insidePopup && !onTool && !onBrushDot && !onColorDot) paintMenu = colorMenu = toolMenu = false; } background(); const float scale = 2.0f * zoom; const int cw = static_cast(sppm::kWidth * scale); const int ch = static_cast(sppm::kHeight * scale); const int cx = (W - cw) / 2 + panX; const int cy = (H - ch) / 2 + panY; rounded(cx - 4, cy - 4, cw + 8, ch + 8, 10, SDL_Color{205, 210, 218, 255}); fill(cx, cy, cw, ch, WHITE); drawCurrentFrame(cx, cy, cw, ch, true); if (canvasGrid) { SDL_Rect canvasClip{cx, cy, cw, ch}; SDL_RenderSetClipRect(renderer, &canvasClip); SDL_SetRenderDrawColor(renderer, 82, 99, 124, 42); // One line per image pixel: at 2x or above this is the actual drawing // pixel grid, not a coarse design grid. for (int x = 0; x <= sppm::kWidth; ++x) { const int px = cx + static_cast(x * cw / static_cast(sppm::kWidth)); SDL_RenderDrawLine(renderer, px, cy, px, cy + ch); } for (int y = 0; y <= sppm::kHeight; ++y) { const int py = cy + static_cast(y * ch / static_cast(sppm::kHeight)); SDL_RenderDrawLine(renderer, cx, py, cx + cw, py); } SDL_RenderSetClipRect(renderer, nullptr); } if (fillWaveActive && fillWaveRadius > 0.0f) { SDL_Rect rippleClip{cx, cy, cw, ch}; SDL_RenderSetClipRect(renderer, &rippleClip); SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); const int front = static_cast(std::floor(fillWaveRadius)); const int pixelW = std::max(1, static_cast(std::ceil(cw / static_cast(sppm::kWidth)))); const int pixelH = std::max(1, static_cast(std::ceil(ch / static_cast(sppm::kHeight)))); // Repeated narrow bands ride the fill front to read as an outward ripple, // not a single uniform expanding disk. for (const auto& entry : fillWavePixels) { const int behindFront = front - static_cast(entry.second); if (behindFront < 0 || (behindFront > 2 && behindFront % 16 > 1)) continue; SDL_SetRenderDrawColor(renderer, 255, 119, 43, behindFront <= 2 ? 235 : 155); const int px = static_cast(entry.first % sppm::kWidth); const int py = static_cast(entry.first / sppm::kWidth); SDL_Rect dot{cx + static_cast(px * cw / static_cast(sppm::kWidth)), cy + static_cast(py * ch / static_cast(sppm::kHeight)), pixelW, pixelH}; SDL_RenderFillRect(renderer, &dot); } SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); SDL_RenderSetClipRect(renderer, nullptr); } const int chromeTravel = static_cast(std::lround((1.0f - canvasChromeVisible) * kCanvasChromeTravel)); const int upOffset = -chromeTravel; const int rightOffset = chromeTravel; const int downOffset = chromeTravel; const int leftOffset = -chromeTravel; for (int i = 0; i < 4; ++i) { const char c = static_cast('A' + i); const std::string path = std::string("ui/CanvasLayerButton") + c + ((layer == i) ? "_Active.png" : "_Inactive.png"); drawImage(path, 20 + leftOffset, 238 + i * 65, 72, 54); if (hit(20, 238 + i * 65, 72, 54)) { layer = i; clickConsumed = true; playEvent("tool_layer", "draw_tool_btn_on"); } } const bool canUndo = !undoStack.empty(); const bool canRedo = !redoStack.empty(); drawImage(canUndo ? "ui/CanvasUndoButton_Active.png" : "ui/CanvasUndoButton_Inactive.png", 20, 18 + upOffset, 68, 54); drawImage(canRedo ? "ui/CanvasRedoButton_Active.png" : "ui/CanvasRedoButton_Inactive.png", 96, 18 + upOffset, 68, 54); if (canUndo && hit(20, 18, 68, 54)) { clickConsumed = true; undo(); } if (canRedo && hit(96, 18, 68, 54)) { clickConsumed = true; redo(); } const auto ink = doc.palette[static_cast(std::clamp(color, 1, 255))]; auto drawToolIcon = [&](Tool tool, int x, int y, int size) { if (tool == Tool::Eraser) { drawImage("ui/icons/EraserTool_HQ.png", x + size / 8, y + size / 8, size * 3 / 4, size * 3 / 4); return; } const char* icon = tool == Tool::Pen ? "ui/icons/PenTool.png" : "ui/icons/PaintTool.png"; drawImage(icon, x, y, size, size); }; const int toolsX = 1204 + rightOffset; rounded(toolsX - 3, 14, 58, 58, 15, WHITE); rounded(toolsX, 17, 52, 52, 12, activeTool == Tool::Pen ? CREAM : LIGHT); drawToolIcon(activeTool, toolsX, 17, 52); if (hit(1204, 17, 52, 52)) { toolMenu = !toolMenu; paintMenu = colorMenu = false; clickConsumed = true; playEvent("tool_pen", "draw_tool_btn_on"); } rounded(toolsX, 84, 52, 52, 12, LIGHT); const int dotSize = std::clamp(brush + 5, 8, 24); const int displayedDotSize = activeTool == Tool::Brush ? (advancedBrush ? 24 : 12) : activeTool == Tool::Eraser ? (eraserSlot == 0 ? 8 : eraserSlot < 4 ? 14 : 22) : dotSize; rounded(toolsX + 26 - displayedDotSize / 2, 110 - displayedDotSize / 2, displayedDotSize, displayedDotSize, displayedDotSize / 2, ink.a ? SDL_Color{ink.r, ink.g, ink.b, 255} : DRAW_BLACK); if (hit(1204, 84, 52, 52)) { paintMenu = !paintMenu; colorMenu = toolMenu = false; clickConsumed = true; playEvent("tool_brush", "draw_tool_btn_on"); } rounded(toolsX, 151, 52, 52, 26, WHITE); rounded(toolsX + 3, 154, 46, 46, 23, SDL_Color{210, 214, 220, 255}); rounded(toolsX + 7, 158, 38, 38, 19, ink.a ? SDL_Color{ink.r, ink.g, ink.b, 255} : DRAW_BLACK); if (hit(1204, 151, 52, 52)) { colorMenu = !colorMenu; paintMenu = toolMenu = false; clickConsumed = true; playEvent("color_menu", "popup"); } rounded(1114, 587 + downOffset, 142, 39, 20, SDL_Color{0, 0, 0, 32}); rounded(1114, 582 + downOffset, 142, 39, 20, WHITE); centered((frame + 1 < 10 ? "000" : frame + 1 < 100 ? "00" : "0") + std::to_string(frame + 1) + " / " + (doc.frames.size() < 10 ? "000" : doc.frames.size() < 100 ? "00" : "0") + std::to_string(doc.frames.size()), 1114, 582 + downOffset, 142, 39, 18, ORANGE, true); if (imageButton("ui/BackButton.png", 20, 635 + downOffset, 64, 62, "button_off")) { if (saveDocument(true)) { expandMetadata = false; screen = Screen::Details; clickConsumed = true; } } const char* menuIcon = canvasMenuOpen ? "ui/CanvasMenuButton_Close.png" : "ui/CanvasMenuButton.png"; if (imageButton(menuIcon, 92, 635 + downOffset, 64, 62, "button_off")) { canvasMenuOpen = !canvasMenuOpen; clickConsumed = true; } if (actionPill("ui/icons/CanvasMenu_ConfigToggle.png", "Options", 474, 640 + downOffset, 142, 48, false, true, "")) { modal = Modal::Options; clickConsumed = true; } if (actionPill("ui/icons/FrameErase.png", "Erase", 630, 640 + downOffset, 162, 48, false, true, "")) { auto& currentLayer = doc.frames[frame].layers[static_cast(layer)]; if (std::any_of(currentLayer.begin(), currentLayer.end(), [](std::uint8_t value) { return value != 0; })) { pushUndo(); std::fill(currentLayer.begin(), currentLayer.end(), 0); markDirty(); playEvent("frame_erase", "edit_page_remove"); } clickConsumed = true; } if (imagePill("ui/icons/SkipFirst.png", 904, 645 + downOffset, 55, 48, false, frame > 0, "")) setFrame(0, true, "frame_first"); if (imagePill("ui/icons/SkipPrev.png", 965, 645 + downOffset, 55, 48, false, frame > 0, "")) setFrame(frame > 5 ? frame - 5 : 0, true, "frame_previous"); playControl(1036, 635 + downOffset, 62, 62); if (imagePill("ui/icons/SkipNext.png", 1108, 645 + downOffset, 55, 48, false, true, "")) { if (frame + 1 < doc.frames.size()) setFrame(frame + 1); else insertFrame(false); } if (imagePill("ui/icons/SkipLast.png", 1169, 645 + downOffset, 58, 48, false, frame + 1 < doc.frames.size(), "")) setFrame(doc.frames.size() - 1, true, "frame_last"); if (canvasMenuProgress > 0.0f) { const int menuX = 154; const int menuY = 635 + downOffset + static_cast(std::lround((1.0f - canvasMenuProgress) * 95.0f)); drawImage("ui/icons/CanvasMenu_PopoutBg.png", menuX, menuY - 9, 310, 80); auto menuIconButton = [&](const char* icon, int x) { drawTexture(whiteImage(icon), x + 4, menuY + 5, 50, 50); return hit(x, menuY + 1, 58, 48); }; if (menuIconButton("ui/icons/CanvasMenu_MusicToggle.png", menuX + 47)) { clickConsumed = true; canvasMenuOpen = false; stopPlayback(); audioTrack = 0; audioEraseArmedTrack = -1; scanImports(); requestAudioUploadServer(); modal = Modal::Audio; modalTransitionStart = SDL_GetTicks(); } else if (menuIconButton("ui/icons/CanvasMenu_FrameToggle.png", menuX + 126)) { canvasMenuOpen = false; screen = Screen::Sequence; clickConsumed = true; } else if (menuIconButton("ui/icons/Camera.svg", menuX + 205)) { canvasMenuOpen = false; openPhotoCapture(); clickConsumed = true; } } if (paintMenu) { rounded(845, 88, 342, 270, 14, SDL_Color{0, 0, 0, 45}); rounded(842, 82, 342, 270, 14, WHITE); rounded(846, 86, 334, 262, 11, LIGHT); const auto inkPreview = ink.a ? SDL_Color{ink.r, ink.g, ink.b, 255} : DRAW_BLACK; rounded(1144, 91, 26, 26, 13, inkPreview); if (activeTool == Tool::Pen) { text("Pen size", 858, 89, 17, INK, true); const std::array brushSizes{{1, 2, 4, 8, 16, 32}}; int selectedBrush = 0; for (int i = 1; i < static_cast(brushSizes.size()); ++i) if (std::abs(brushSizes[i] - brush) < std::abs(brushSizes[selectedBrush] - brush)) selectedBrush = i; for (int i = 0; i < 6; ++i) { const int bx = 866 + i * 51; if (i == selectedBrush && !square) rounded(bx - 3, 109, 37, 37, 7, ORANGE); if (i == selectedBrush && square) rounded(bx - 3, 158, 37, 37, 7, ORANGE); const int markSize = std::clamp(brushSizes[i], 4, 29); const int markOffset = (31 - markSize) / 2; rounded(bx + markOffset, 112 + markOffset, markSize, markSize, markSize / 2, SDL_Color{28, 30, 35, 255}); rounded(bx + markOffset, 161 + markOffset, markSize, markSize, 2, SDL_Color{28, 30, 35, 255}); if (hit(bx, 112, 31, 31) || hit(bx, 161, 31, 31)) { brush = brushSizes[i]; square = pointerY > 150; clickConsumed = true; playEvent("tool_brush", "draw_tool_btn_on"); } } static const std::array penNames{{ "1 px", "Dir 2 px", "3x3 square", "Squiggle", "Directional =", "Sparse air", "Jitter line", "Dense spray", "5x5 square", "7x7 square", "9x9 square", "Dynamic width", "Hollow diamond", "Large air", "Wide jitter", "Heavy spray" }}; for (int i = 0; i < static_cast(penNames.size()); ++i) { const int x = 855 + (i % 4) * 80; const int y = 203 + (i / 4) * 20; rounded(x, y, 77, 18, 5, penType == i ? ORANGE : WHITE); centered(penNames[static_cast(i)], x + 1, y, 75, 18, 11, penType == i ? WHITE : INK); if (hit(x, y, 77, 18)) { penType = i; squiggleAnchorX = squiggleAnchorY = ringPreviousX = ringPreviousY = -1; clickConsumed = true; playEvent("tool_pen", "draw_tool_btn_on"); } } if (pill("−", 1093, 291, 38, 32, false, 20)) brush = std::max(1, brush - 1); if (pill("+", 1134, 291, 38, 32, false, 20)) brush = std::min(32, brush + 1); rounded(864, 287, 32, 32, 16, inkPreview); } else if (activeTool == Tool::Brush) { text("Brush patterns", 858, 89, 17, INK, true); for (int i = 0; i < 8; ++i) { const int x = 859 + (i % 4) * 78; const int y = 124 + (i / 4) * 48; rounded(x - 2, y - 2, 70, 42, 6, pattern == i ? ORANGE : WHITE); rounded(x, y, 66, 38, 4, SDL_Color{255, 255, 255, 255}); SDL_Rect clip{x, y, 66, 38}; SDL_RenderSetClipRect(renderer, &clip); for (int py = 0; py < 38; ++py) { for (int px = 0; px < 66; ++px) { if (switchnoteBrushPattern(i, px / 2, py / 2)) fill(x + px, y + py, 1, 1, inkPreview); } } SDL_RenderSetClipRect(renderer, nullptr); if (hit(x - 2, y - 2, 70, 42)) { pattern = i; clickConsumed = true; playEvent("tool_brush", "draw_tool_btn_on"); } } rounded(859, 227, 183, 39, 11, advancedBrush ? CREAM : WHITE); text(advancedBrush ? "Advanced brush ON" : "Advanced brush OFF", 873, 237, 16, advancedBrush ? ORANGE : INK, true); if (hit(859, 227, 183, 39)) { advancedBrush = !advancedBrush; clickConsumed = true; playEvent("tool_brush", "draw_tool_btn_on"); } } else { text("Erase tools", 858, 89, 17, INK, true); const std::array eraseLabels{{"Pixel", "3×3", "Round M", "Round L", "9×9", "15×15", "Vertical", "Horizontal"}}; for (int i = 0; i < 8; ++i) { const int x = 854 + (i % 4) * 81; const int y = 118 + (i / 4) * 83; rounded(x, y, 72, 73, 9, eraserSlot == i ? CREAM : WHITE); const int centerX = x + 36, centerY = y + 28; for (int dy = -7; dy <= 7; ++dy) for (int dx = -7; dx <= 7; ++dx) if (switchnoteEraserContains(i, dx, dy)) fill(centerX + dx * 2 - 1, centerY + dy * 2 - 1, 2, 2, INK); centered(eraseLabels[static_cast(i)], x + 2, y + 51, 68, 17, 12, INK); if (hit(x, y, 72, 73)) { eraserSlot = i; clickConsumed = true; playEvent("tool_brush", "draw_tool_btn_on"); } } } } if (colorMenu) { rounded(845, 88, 342, 162, 14, SDL_Color{0, 0, 0, 45}); rounded(842, 82, 342, 162, 14, WHITE); rounded(846, 86, 334, 154, 11, LIGHT); text("Ink color", 858, 89, 17, INK, true); for (int i = 0; i < static_cast(kInkPresetIndexes.size()); ++i) { const int index = kInkPresetIndexes[static_cast(i)]; const int x = 875 + (i % 5) * 57, y = 126 + (i / 5) * 54; const auto c = doc.palette[static_cast(index)]; if (index == color) rounded(x - 3, y - 3, 42, 42, 9, ORANGE); rounded(x, y, 36, 36, 18, SDL_Color{c.r, c.g, c.b, 255}); if (hit(x - 3, y - 3, 42, 42)) { color = index; colorMenu = false; clickConsumed = true; playEvent("tool_color", "draw_tool_btn_on"); } } } if (toolMenu) { rounded(845, 88, 342, 154, 14, SDL_Color{0, 0, 0, 45}); rounded(842, 82, 342, 154, 14, WHITE); rounded(846, 86, 334, 146, 11, LIGHT); text("Tools", 858, 89, 17, INK, true); const std::array tools{{Tool::Pen, Tool::Brush, Tool::Eraser}}; const std::array labels{{"Pen", "Brush", "Eraser"}}; for (int i = 0; i < 3; ++i) { const int x = 856 + i * 106; const int y = 121; rounded(x - 2, y - 2, 96, 78, 12, activeTool == tools[i] ? CREAM : WHITE); drawToolIcon(tools[i], x + 20, y + 1, 48); centered(labels[i], x, y + 47, 92, 24, 15, activeTool == tools[i] ? ORANGE : INK); if (hit(x - 2, y - 2, 96, 78)) { activeTool = tools[i]; toolMenu = false; clickConsumed = true; playEvent("tool_pen", "draw_tool_btn_on"); } } } } void App::renderViewer() { if (!docLoaded) { screen = Screen::Library; return; } const float scale = std::min(W / static_cast(sppm::kWidth), H / static_cast(sppm::kHeight)); const int cw = static_cast(sppm::kWidth * scale); const int ch = static_cast(sppm::kHeight * scale); const int cx = (W - cw) / 2 + panX; const int cy = (H - ch) / 2 + panY; fill(0, 0, W, H, SDL_Color{255, 255, 255, 255}); drawCurrentFrame(cx, cy, cw, ch, false); if (pill("Back", 20, 18, 104, 46)) { stopPlayback(); screen = galleryViewing ? Screen::GalleryDetails : Screen::Details; } if (galleryViewing && pill("Comments", 1080, 18, 170, 46, true)) openGalleryComments(); if (imagePill("ui/icons/SkipFirst.png", 913, 645, 55, 48, false, frame > 0, "")) setFrame(0, true, "frame_first"); if (imagePill("ui/icons/SkipPrev.png", 974, 645, 55, 48, false, frame > 0, "")) setFrame(frame > 5 ? frame - 5 : 0, true, "frame_previous"); playControl(1044, 633, 70, 66); if (imagePill("ui/icons/SkipNext.png", 1116, 645, 55, 48, false, frame + 1 < doc.frames.size(), "")) setFrame(frame + 1, true, "frame_next"); if (imagePill("ui/icons/SkipLast.png", 1177, 645, 58, 48, false, frame + 1 < doc.frames.size(), "")) setFrame(doc.frames.size() - 1, true, "frame_last"); rounded(1114, 584, 142, 40, 20, WHITE); centered(std::to_string(frame + 1) + " / " + std::to_string(doc.frames.size()), 1114, 584, 142, 40, 18, ORANGE, true); } void App::renderComments() { background(); if (gallery.empty()) { if (imageButton("ui/BackButton_Gallery.png", 39, 620, 65, 65)) screen = Screen::Gallery; return; } const Item& item = gallery[gallerySelected % gallery.size()]; if (imageButton("ui/BackButton_Gallery.png", 39, 35, 65, 65)) screen = Screen::GalleryDetails; text("Comments", 126, 39, 32, INK, true); text(utf8Prefix(item.title, 44), 126, 79, 19, GRAY); if (!galleryCommentsLoaded && galleryComments.empty()) centered("Connecting to Gallery…", 350, 245, 580, 45, 20, GRAY); else if (galleryComments.empty()) centered("No comments yet.", 350, 245, 580, 45, 24, INK); else { constexpr int viewportTop = 130; constexpr int viewportHeight = 490; auto commentHasInk = [](const GalleryComment& comment) { return comment.drawing.size() == sppm::kPixelsPerLayer && std::any_of(comment.drawing.begin(), comment.drawing.end(), [](std::uint8_t pixel) { return pixel != 0; }); }; std::vector> wrappedComments(galleryComments.size()); std::vector heights; heights.reserve(galleryComments.size()); int contentHeight = 0; TTF_Font* commentFont = font(19, false); for (std::size_t index = 0; index < galleryComments.size(); ++index) { const auto& comment = galleryComments[index]; const bool hasDrawing = commentHasInk(comment); if (!comment.text.empty() && commentFont) { const std::string display = brandString(comment.text); std::string line, word; std::istringstream words(display); while (words >> word) { int wordWidth = 0, wordHeight = 0; TTF_SizeUTF8(commentFont, word.c_str(), &wordWidth, &wordHeight); if (wordWidth > 620) { if (!line.empty()) { wrappedComments[index].push_back(line); line.clear(); } std::string fragment; for (std::size_t offset = 0; offset < word.size();) { const unsigned char lead = static_cast(word[offset]); const std::size_t count = lead < 0x80 ? 1 : (lead & 0xE0) == 0xC0 ? 2 : (lead & 0xF0) == 0xE0 ? 3 : 4; const std::string glyph = word.substr(offset, count); const std::string candidate = fragment + glyph; int width = 0, height = 0; TTF_SizeUTF8(commentFont, candidate.c_str(), &width, &height); if (!fragment.empty() && width > 620) { wrappedComments[index].push_back(fragment); fragment = glyph; } else fragment = candidate; offset += count; } line = fragment; continue; } const std::string candidate = line.empty() ? word : line + " " + word; int measured = 0, measuredHeight = 0; TTF_SizeUTF8(commentFont, candidate.c_str(), &measured, &measuredHeight); if (!line.empty() && measured > 620) { wrappedComments[index].push_back(line); line = word; } else line = candidate; } if (!line.empty()) wrappedComments[index].push_back(line); } const int lineCount = static_cast(wrappedComments[index].size()); const int textBottom = lineCount ? 43 + lineCount * 23 : 0; const int drawingTop = std::max(88, textBottom + 6); const int height = hasDrawing ? drawingTop + 332 + 10 : std::max(88, textBottom + 12); heights.push_back(height); contentHeight += height + 12; } const int maxScroll = std::max(0, contentHeight - viewportHeight); galleryCommentScroll = std::clamp(galleryCommentScroll, 0, maxScroll); SDL_Rect clip{197, viewportTop, 862, viewportHeight}; SDL_RenderSetClipRect(renderer, &clip); int virtualY = viewportTop + 7 - galleryCommentScroll; for (std::size_t index = 0; index < galleryComments.size(); ++index) { const auto& comment = galleryComments[index]; const int cardHeight = heights[index]; const int y = virtualY; if (y + cardHeight >= viewportTop && y <= viewportTop + viewportHeight) { rounded(318, y + 5, 652, cardHeight, 13, SDL_Color{0, 0, 0, 35}); rounded(318, y, 652, cardHeight, 13, WHITE); const bool hasDrawing = commentHasInk(comment); const int avatarSize = hasDrawing ? 60 : 52; rounded(208, y + 8, avatarSize, avatarSize, 10, LIGHT); centered(comment.author.empty() ? "?" : utf8Prefix(comment.author, 1), 208, y + 8, avatarSize, avatarSize, hasDrawing ? 27 : 23, INK, true); text(utf8Prefix(comment.author, 25), 284, y + 11, 21, INK, true); text(comment.created, 784, y + 13, 17, GRAY); for (std::size_t line = 0; line < wrappedComments[index].size(); ++line) text(wrappedComments[index][line], 284, y + 43 + static_cast(line) * 23, 19, INK); if (hasDrawing) if (SDL_Texture* drawing = galleryCommentTexture(comment, index)) { const int drawingY = y + std::max(88, (wrappedComments[index].empty() ? 0 : 43 + static_cast(wrappedComments[index].size()) * 23) + 6); // SPPM comment ink is 640x360. Keep its 16:9 drawing area intact. drawTexture(drawing, 340, drawingY, 590, 332); } } virtualY += cardHeight + 12; } SDL_RenderSetClipRect(renderer, nullptr); if (maxScroll > 0) { fill(1205, 146, 6, 450, SDL_Color{255, 190, 218, 255}); const int thumbHeight = std::max(54, viewportHeight * viewportHeight / contentHeight); const int thumbY = 146 + ((viewportHeight - thumbHeight) * galleryCommentScroll / maxScroll); fill(1205, thumbY, 6, thumbHeight, GREEN); } } if (imageButton("ui/BackButton_Gallery.png", 39, 620, 65, 65)) screen = Screen::GalleryDetails; if (pill("Post Comment Y", 1022, 633, 218, 47, true, 20)) screen = Screen::CommentCanvas; } void App::renderCommentCanvas() { background(); if (!commentTexture || commentDirty) { if (commentTexture) SDL_DestroyTexture(commentTexture); SDL_Surface* surface = SDL_CreateRGBSurfaceWithFormat(0, sppm::kWidth, sppm::kHeight, 32, SDL_PIXELFORMAT_RGBA32); if (surface) { auto* rgba = static_cast(surface->pixels); for (std::size_t i = 0; i < sppm::kPixelsPerLayer; ++i) rgba[i] = commentPixels[i] ? SDL_MapRGBA(surface->format, 20, 22, 28, 255) : SDL_MapRGBA(surface->format, 0, 0, 0, 0); commentTexture = SDL_CreateTextureFromSurface(renderer, surface); if (commentTexture) SDL_SetTextureScaleMode(commentTexture, SDL_ScaleModeNearest); SDL_FreeSurface(surface); } commentDirty = false; } fill(0, 0, W, H, WHITE); if (commentTexture) drawTexture(commentTexture, 0, 0, W, H); if (pill("Text", 20, 20, 115, 44, true, 18)) beginText(Field::CommentText, ""); if (!commentDraft.empty()) text(utf8Prefix(commentDraft, 72), 158, 27, 19, INK); if (pill("Cancel", 22, 644, 134, 45, true, 20)) screen = Screen::GalleryComments; if (pill("Clear X", 1003, 644, 117, 45, true, 20)) { commentPixels.assign(sppm::kPixelsPerLayer, 0); commentDirty = true; } if (pill("Send Y", 1142, 644, 116, 45, true, 20)) postGalleryComment(); } void App::beginText(Field field, const std::string& current) { #ifdef __SWITCH__ SwkbdConfig keyboard; if (R_SUCCEEDED(swkbdCreate(&keyboard, 0))) { swkbdConfigMakePresetDefault(&keyboard); swkbdConfigSetInitialText(&keyboard, current.c_str()); const char* header = field == Field::Title ? "Switchnote title" : field == Field::Description ? "Description" : field == Field::FolderName ? "New folder" : field == Field::GalleryBio ? "Gallery bio" : "Comment"; swkbdConfigSetHeaderText(&keyboard, header); char buffer[257]{}; if (R_SUCCEEDED(swkbdShow(&keyboard, buffer, sizeof(buffer)))) { editingField = field; inputText = buffer; applyText(); } swkbdClose(&keyboard); } #else editingField = field; inputText = current; modal = Modal::TextInput; SDL_StartTextInput(); #endif } void App::applyText() { SDL_StopTextInput(); const std::string value = inputText.substr(0, editingField == Field::GalleryBio ? 200 : 240); switch (editingField) { case Field::Title: if (docLoaded && !value.empty()) { doc.metadata.title = value; markDirty(); } break; case Field::Description: if (docLoaded) { doc.metadata.description = value; markDirty(); } break; case Field::FolderName: { std::string safe; for (unsigned char ch : value) if (std::isalnum(ch) || ch == ' ' || ch == '_' || ch == '-') safe.push_back(static_cast(ch)); if (!safe.empty()) { ensureDir(join(notesRoot, safe)); folder = safe; selected = 0; scanLibrary(); showToast("Folder created"); } break; } case Field::CommentText: if (!value.empty()) { commentDraft = value; screen = Screen::CommentCanvas; } break; case Field::GalleryBio: if (galleryProfileIsOwn) { galleryProfile.bio = value; saveGalleryProfile(); } break; case Field::None: break; } inputText.clear(); editingField = Field::None; modal = Modal::None; } bool App::importAudioFile(const std::string& path, int targetTrack, const std::string& displayName) { if (!docLoaded || targetTrack < 0 || targetTrack >= 4) return false; SDL_AudioSpec source{}; Uint8* raw = nullptr; Uint32 rawLength = 0; if (!SDL_LoadWAV(path.c_str(), &source, &raw, &rawLength)) { showToast("Could not read WAV: " + std::string(SDL_GetError())); return false; } const int bytesPerSample = SDL_AUDIO_BITSIZE(source.format) / 8; if (source.freq <= 0 || source.channels < 1 || source.channels > 2 || bytesPerSample <= 0 || rawLength == 0 || rawLength > static_cast(std::numeric_limits::max())) { SDL_FreeWAV(raw); showToast("Use a valid mono or stereo WAV file"); return false; } const std::size_t bytesPerFrame = static_cast(source.channels) * bytesPerSample; const std::size_t availableFrames = rawLength / bytesPerFrame; const int maximumSeconds = targetTrack == 0 ? 60 : 2; const std::size_t maximumFrames = static_cast(maximumSeconds) * static_cast(source.freq); const std::size_t framesToKeep = std::min(availableFrames, maximumFrames); if (!framesToKeep) { SDL_FreeWAV(raw); showToast("This audio file is empty"); return false; } const bool trimmed = framesToKeep < availableFrames; SDL_AudioCVT cvt{}; const int build = SDL_BuildAudioCVT(&cvt, source.format, source.channels, source.freq, AUDIO_S16SYS, 2, 48000); if (build < 0) { SDL_FreeWAV(raw); showToast("WAV format is not supported"); return false; } cvt.len = static_cast(framesToKeep * bytesPerFrame); cvt.buf = static_cast(SDL_malloc(static_cast(cvt.len) * cvt.len_mult)); if (!cvt.buf) { SDL_FreeWAV(raw); showToast("Not enough memory to import audio"); return false; } std::memcpy(cvt.buf, raw, static_cast(cvt.len)); SDL_FreeWAV(raw); if (cvt.needed && SDL_ConvertAudio(&cvt) != 0) { SDL_free(cvt.buf); showToast("Could not convert WAV audio"); return false; } if (!cvt.needed) cvt.len_cvt = cvt.len; const std::size_t sampleCount = static_cast(cvt.len_cvt) / sizeof(std::int16_t); constexpr std::size_t maxAudioSamples = 32ULL * 1024 * 1024; std::size_t otherSamples = 0; for (std::size_t i = 0; i < doc.audioTracks.size(); ++i) if (i != static_cast(targetTrack)) otherSamples += doc.audioTracks[i].pcm.size(); if (!cvt.len_cvt || sampleCount > maxAudioSamples || otherSamples > maxAudioSamples - sampleCount) { SDL_free(cvt.buf); showToast("There is not enough Switchnote space for this audio"); return false; } while (doc.audioTracks.size() <= static_cast(targetTrack)) { sppm::AudioTrack blank; blank.name = doc.audioTracks.empty() ? "BGM" : "SE" + std::to_string(doc.audioTracks.size()); blank.sampleRate = 48000; blank.channels = 2; doc.audioTracks.push_back(std::move(blank)); } auto& track = doc.audioTracks[static_cast(targetTrack)]; track.name = targetTrack == 0 ? "BGM" : "SE" + std::to_string(targetTrack); track.sampleRate = 48000; track.channels = 2; track.referenceFps = 0; track.referenceSpeed = targetTrack == 0 ? doc.metadata.speed : 0; track.pcm.resize(sampleCount); std::memcpy(track.pcm.data(), cvt.buf, sampleCount * sizeof(std::int16_t)); SDL_free(cvt.buf); if (targetTrack > 0 && track.frameTriggers.empty()) track.frameTriggers = {static_cast(frame)}; markDirty(); rebuildAudio(); if (!saveDocument(true)) return false; showToast("Imported " + displayName + " to " + track.name + (trimmed ? " (trimmed)" : "")); return true; } void App::importAudio() { if (!docLoaded || importFiles.empty()) { showToast("Add WAV files to the sdmc:/switch/Switchnote/import folder"); return; } const std::string filename = importFiles[importSelection % importFiles.size()]; importAudioFile(join(importRoot, filename), audioTrack, filename); } void App::exportDocument(int destination) { if (!docLoaded) return; std::string base = doc.metadata.title.empty() ? "Switchnote" : doc.metadata.title; for (char& ch : base) if (!std::isalnum(static_cast(ch)) && ch != '-' && ch != '_') ch = '_'; std::string error; if (destination == 0) { const std::string directory = "sdmc:/Switchnotes"; ensureDir(directory); const std::string path = join(directory, base + ".mp4"); const bool ok = sppm::exportMp4(doc, path, error); showToast(ok ? "Silent MP4 exported to " + path : "Export failed: " + error); return; } #ifdef __SWITCH__ std::vector image(static_cast(1280) * 720 * 4, 255); const auto& cover = doc.frames[doc.metadata.coverFrame < doc.frames.size() ? doc.metadata.coverFrame : 0]; for (std::size_t y = 0; y < 720; ++y) { for (std::size_t x = 0; x < 1280; ++x) { const std::size_t pixel = (y / 2) * sppm::kWidth + x / 2; unsigned red = 255, green = 255, blue = 255; for (std::size_t layerIndex = cover.layers.size(); layerIndex-- > 0;) { const std::uint8_t index = cover.layers[layerIndex][pixel]; if (!index) continue; const auto& color = doc.palette[index]; const unsigned alpha = color.a; red = (color.r * alpha + red * (255 - alpha) + 127) / 255; green = (color.g * alpha + green * (255 - alpha) + 127) / 255; blue = (color.b * alpha + blue * (255 - alpha) + 127) / 255; } const std::size_t offset = (y * 1280 + x) * 4; image[offset] = static_cast(red); image[offset + 1] = static_cast(green); image[offset + 2] = static_cast(blue); image[offset + 3] = 255; } } Result initResult = capssuInitialize(); if (R_FAILED(initResult)) { showToast("Could not open System Captures (" + std::to_string(initResult) + ")"); return; } CapsApplicationAlbumEntry entry{}; const Result saveResult = capssuSaveScreenShot(image.data(), image.size(), AlbumReportOption_Enable, AlbumImageOrientation_Unknown0, &entry); capssuExit(); showToast(R_SUCCEEDED(saveResult) ? "Cover saved to System Captures" : "System Captures export failed (" + std::to_string(saveResult) + ")"); #else const std::string path = join(exportRoot, base + ".png"); const bool ok = sppm::exportPngCover(doc, path, error); showToast(ok ? "System Captures export is Switch-only; saved cover to " + path : "Export failed: " + error); #endif } void App::openPhotoCapture() { if (!docLoaded || frame >= doc.frames.size()) return; stopPlayback(false); #ifdef __SWITCH__ if (photoProbeRunning) return; irPhotoTaken = false; irRotatePhoto = false; irImageReady = false; irImageState = {}; photoProbeRunning = true; photoProbeHasCamera = false; photoProbeFinished.store(false, std::memory_order_relaxed); beginLoading(); modal = Modal::PhotoProbe; modalTransitionStart = SDL_GetTicks(); if (R_FAILED(threadCreate(&photoProbeThread, photoProbeThreadEntry, this, nullptr, 0x10000, 0x3B, -2))) { photoProbeRunning = false; endLoading(); modal = Modal::None; openPhotoUpload(); return; } photoProbeThreadCreated = true; if (R_FAILED(threadStart(&photoProbeThread))) { threadClose(&photoProbeThread); photoProbeThreadCreated = false; photoProbeRunning = false; endLoading(); modal = Modal::None; openPhotoUpload(); } #else openPhotoUpload(); #endif } #ifdef __SWITCH__ void App::photoProbeThreadEntry(void* argument) { auto* app = static_cast(argument); app->runPhotoProbeThread(); threadExit(); } void App::runPhotoProbeThread() { irCameraRunning = false; irsReady = irsInitialize() == 0; if (irsReady) { // IR probing can take several service round-trips on consoles with // detached/reassigned Joy-Cons. Keep all of it off the render thread. const bool handheld = (hidGetNpadStyleSet(HidNpadIdType_Handheld) & HidNpadStyleTag_NpadHandheld) != 0; const std::array cameraCandidates = handheld ? std::array{HidNpadIdType_Handheld, HidNpadIdType_No1, HidNpadIdType_No2, HidNpadIdType_No3, HidNpadIdType_No4, HidNpadIdType_No5, HidNpadIdType_No6, HidNpadIdType_No7, HidNpadIdType_No8} : std::array{HidNpadIdType_No1, HidNpadIdType_No2, HidNpadIdType_No3, HidNpadIdType_No4, HidNpadIdType_No5, HidNpadIdType_No6, HidNpadIdType_No7, HidNpadIdType_No8, HidNpadIdType_Handheld}; IrsImageTransferProcessorConfig config{}; irsGetDefaultImageTransferProcessorConfig(&config); config.format = IrsImageTransferProcessorFormat_320x240; for (const HidNpadIdType id : cameraCandidates) { IrsIrCameraHandle candidate{}; if (R_FAILED(irsGetIrCameraHandle(&candidate, id))) continue; IrsIrCameraStatus status{}; if (R_FAILED(irsGetIrCameraStatus(candidate, &status)) || status != IrsIrCameraStatus_Available) continue; if (R_FAILED(irsRunImageTransferProcessor(candidate, &config, 0x100000))) continue; irCamera = candidate; irCameraRunning = true; break; } if (!irCameraRunning) for (const HidNpadIdType id : cameraCandidates) { IrsIrCameraHandle candidate{}; if (R_FAILED(irsGetIrCameraHandle(&candidate, id))) continue; if (R_FAILED(irsRunImageTransferProcessor(candidate, &config, 0x100000))) continue; irCamera = candidate; irCameraRunning = true; break; } } if (!irCameraRunning && irsReady) { irsExit(); irsReady = false; } photoProbeHasCamera = irCameraRunning; photoProbeFinished.store(true, std::memory_order_release); } #endif void App::consumePhotoProbe() { #ifdef __SWITCH__ if (!photoProbeRunning || !photoProbeFinished.exchange(false, std::memory_order_acquire)) return; if (photoProbeThreadCreated) { threadWaitForExit(&photoProbeThread); threadClose(&photoProbeThread); photoProbeThreadCreated = false; } photoProbeRunning = false; endLoading(); if (photoProbeHasCamera) { if (!irPreviewTexture && renderer) irPreviewTexture = SDL_CreateTexture(renderer, SDL_PIXELFORMAT_RGBA32, SDL_TEXTUREACCESS_STREAMING, 320, 240); modal = Modal::PhotoChoice; modalTransitionStart = SDL_GetTicks(); } else { modal = Modal::None; openPhotoUpload(); } #endif } void App::openPhotoUpload() { stopPlayback(false); modal = Modal::PhotoUpload; modalTransitionStart = SDL_GetTicks(); requestAudioUploadServer(true); } void App::openPhotoAlbum() { #ifdef __SWITCH__ for (SDL_Texture*& texture : photoAlbumThumbs) { if (texture) SDL_DestroyTexture(texture); texture = nullptr; } photoAlbumEntries.clear(); photoAlbumPage = 0; photoAlbumThumbIndexes.fill(std::numeric_limits::max()); if (R_FAILED(capsaInitialize())) { showToast("Could not open the Switch photo album."); return; } u64 count = 0; Result result = capsaGetAlbumFileCount(CapsAlbumStorage_Sd, &count); if (R_FAILED(result) || count == 0) result = capsaGetAlbumFileCount(CapsAlbumStorage_Nand, &count); if (R_FAILED(result) || count == 0) { capsaExit(); showToast(count == 0 ? "No photos were found in the album." : "Could not read the Switch photo album."); return; } count = std::min(count, 2048); std::vector entries(static_cast(count)); u64 written = 0; result = capsaGetAlbumFileList(CapsAlbumStorage_Sd, &written, entries.data(), entries.size()); if (R_FAILED(result) || written == 0) result = capsaGetAlbumFileList(CapsAlbumStorage_Nand, &written, entries.data(), entries.size()); capsaExit(); if (R_FAILED(result) || written == 0) { showToast("Could not read photos from the Switch album."); return; } entries.resize(static_cast(std::min(written, entries.size()))); for (const auto& entry : entries) if (entry.file_id.content == CapsContentType_Screenshot) photoAlbumEntries.push_back(entry); std::sort(photoAlbumEntries.begin(), photoAlbumEntries.end(), [](const CapsAlbumEntry& a, const CapsAlbumEntry& b) { const auto& x = a.file_id.datetime; const auto& y = b.file_id.datetime; if (x.year != y.year) return x.year > y.year; if (x.month != y.month) return x.month > y.month; if (x.day != y.day) return x.day > y.day; if (x.hour != y.hour) return x.hour > y.hour; if (x.minute != y.minute) return x.minute > y.minute; if (x.second != y.second) return x.second > y.second; return x.id > y.id; }); if (photoAlbumEntries.empty()) { showToast("No screenshots were found in the Switch photo album."); return; } modal = Modal::PhotoAlbum; modalTransitionStart = SDL_GetTicks(); #else showToast("The Switch photo album is only available on Nintendo Switch."); #endif } void App::importPhotoAlbumEntry(std::size_t index) { #ifdef __SWITCH__ if (!docLoaded || frame >= doc.frames.size() || index >= photoAlbumEntries.size()) return; if (R_FAILED(capsaInitialize())) { showToast("Could not open the Switch photo album."); return; } const CapsAlbumEntry& entry = photoAlbumEntries[index]; if (entry.size == 0 || entry.size > 32U * 1024U * 1024U) { capsaExit(); showToast("That album image is too large to import."); return; } std::vector encoded(static_cast(entry.size)); u64 loaded = 0; const Result result = capsaLoadAlbumFile(&entry.file_id, &loaded, encoded.data(), encoded.size()); capsaExit(); if (R_FAILED(result) || loaded == 0 || loaded > encoded.size()) { showToast("Could not load that album image."); return; } SDL_RWops* stream = SDL_RWFromConstMem(encoded.data(), static_cast(loaded)); SDL_Surface* decoded = stream ? IMG_Load_RW(stream, 1) : nullptr; if (!decoded || decoded->w <= 0 || decoded->h <= 0 || decoded->w > 8192 || decoded->h > 8192) { if (decoded) SDL_FreeSurface(decoded); showToast("That album image could not be decoded."); return; } SDL_Surface* rgba = SDL_ConvertSurfaceFormat(decoded, SDL_PIXELFORMAT_RGBA32, 0); SDL_FreeSurface(decoded); if (!rgba) { showToast("Could not prepare the album image."); return; } photoImportRgba.assign(600U * sppm::kHeight * 4U, 0); const float scale = std::min(600.0f / rgba->w, static_cast(sppm::kHeight) / rgba->h); const int drawW = std::max(1, static_cast(std::lround(rgba->w * scale))); const int drawH = std::max(1, static_cast(std::lround(rgba->h * scale))); const int left = (600 - drawW) / 2, top = (static_cast(sppm::kHeight) - drawH) / 2; if (SDL_MUSTLOCK(rgba)) SDL_LockSurface(rgba); for (int y = 0; y < drawH; ++y) { const int sy = std::min(rgba->h - 1, y * rgba->h / drawH); const auto* row = static_cast(rgba->pixels) + static_cast(sy) * rgba->pitch; for (int x = 0; x < drawW; ++x) { const int sx = std::min(rgba->w - 1, x * rgba->w / drawW); const auto* src = row + static_cast(sx) * 4U; auto* dst = photoImportRgba.data() + (static_cast(top + y) * 600U + left + x) * 4U; std::memcpy(dst, src, 4U); } } if (SDL_MUSTLOCK(rgba)) SDL_UnlockSurface(rgba); SDL_FreeSurface(rgba); photoImportLayer = std::clamp(layer, 0, 3); photoImportRow = 0; pushUndo(); std::fill(doc.frames[frame].layers[static_cast(photoImportLayer)].begin(), doc.frames[frame].layers[static_cast(photoImportLayer)].end(), 0); photoImporting = true; modal = Modal::None; beginLoading(); #else (void)index; #endif } void App::closePhotoCapture() { #ifdef __SWITCH__ if (irCameraRunning) { irsStopImageProcessor(irCamera); irCameraRunning = false; } if (irsReady) { irsExit(); irsReady = false; } #endif if (photoTransferActive) stopAudioUploadServer(); modal = Modal::None; } void App::updatePhotoCapture() { #ifdef __SWITCH__ if (!irsReady || !irCameraRunning || irPhotoTaken) return; const std::uint64_t previousSample = irImageState.sampling_number; if (R_FAILED(irsGetImageTransferProcessorState(irCamera, irImage.data(), irImage.size(), &irImageState))) return; if (irImageReady && irImageState.sampling_number == previousSample) return; irImageReady = true; for (std::size_t i = 0; i < irImage.size(); ++i) { irPreviewRgba[i * 4] = irImage[i]; irPreviewRgba[i * 4 + 1] = irImage[i]; irPreviewRgba[i * 4 + 2] = irImage[i]; } if (irPreviewTexture) SDL_UpdateTexture(irPreviewTexture, nullptr, irPreviewRgba.data(), 320 * 4); #endif } void App::importPhoto() { #ifdef __SWITCH__ if (!docLoaded || !irPhotoTaken || frame >= doc.frames.size()) return; pushUndo(); auto& pixels = doc.frames[frame].layers[static_cast(layer)]; std::fill(pixels.begin(), pixels.end(), 0); // Keep the IR image's 4:3 proportions, centered on the 16:9 Switchnote // canvas. Its native 320x240 pixels are doubled without interpolation. const int targetW = irRotatePhoto ? 270 : 480; constexpr int targetH = 360; const int left = (sppm::kWidth - targetW) / 2; const auto palette = doc.palette; std::array grayToPalette{}; for (int shade = 0; shade < 256; ++shade) { int bestError = std::numeric_limits::max(); std::uint8_t bestIndex = 1; for (int index = 16; index < 256; ++index) { const auto& c = palette[static_cast(index)]; const int luminance = (c.r * 299 + c.g * 587 + c.b * 114) / 1000; const int error = std::abs(luminance - shade); if (error < bestError) { bestError = error; bestIndex = static_cast(index); } } grayToPalette[static_cast(shade)] = bestIndex; } for (int y = 0; y < targetH; ++y) { for (int x = 0; x < targetW; ++x) { const int rotatedX = irRotatePhoto ? x * 240 / targetW : 0; const int rotatedY = irRotatePhoto ? y * 320 / targetH : 0; const int sourceX = irRotatePhoto ? rotatedY : x / 1.5; const int sourceY = irRotatePhoto ? 239 - rotatedX : y / 1.5; const auto gray = irPhoto[static_cast(sourceY) * 320 + sourceX]; pixels[static_cast(y) * sppm::kWidth + left + x] = grayToPalette[gray]; } } markDirty(); invalidateFrames(); showToast("Photo imported to the selected layer"); #endif closePhotoCapture(); } bool App::importUploadedPhoto() { if (photoImporting) return false; if (!docLoaded || frame >= doc.frames.size()) return false; const std::string imagePath = join(importRoot, "photo-transfer.rgba"); const std::string optionsPath = join(importRoot, "photo-transfer.cfg"); std::ifstream image(imagePath, std::ios::binary | std::ios::ate); constexpr std::size_t photoBytes = 600U * sppm::kHeight * 4U; if (!image || image.tellg() != static_cast(photoBytes)) return false; photoImportRgba.resize(photoBytes); image.seekg(0, std::ios::beg); image.read(reinterpret_cast(photoImportRgba.data()), static_cast(photoImportRgba.size())); if (!image) return false; std::ifstream options(optionsPath, std::ios::binary); int targetLayer = -1; std::string mode, dither, size, offsetX, offsetY; std::string line; if (!std::getline(options, line)) return false; targetLayer = std::atoi(line.c_str()); if (!std::getline(options, mode) || !std::getline(options, dither) || !std::getline(options, size) || !std::getline(options, offsetX) || !std::getline(options, offsetY) || targetLayer < 0 || targetLayer >= 4) return false; (void)mode; (void)dither; (void)size; (void)offsetX; (void)offsetY; photoImportLayer = targetLayer; photoImportRow = 0; layer = targetLayer; pushUndo(); std::fill(doc.frames[frame].layers[static_cast(targetLayer)].begin(), doc.frames[frame].layers[static_cast(targetLayer)].end(), 0); photoImporting = true; beginLoading(); return true; } void App::advancePhotoImport() { if (!photoImporting) return; constexpr int rowsPerUpdate = 4; auto& pixels = doc.frames[frame].layers[static_cast(photoImportLayer)]; const int endRow = std::min(sppm::kHeight, photoImportRow + rowsPerUpdate); for (int y = photoImportRow; y < endRow; ++y) { for (int x = 0; x < sppm::kWidth; ++x) { // The browser preview is 600x360 while the Switchnote canvas is // 640x360. Center that one preview exactly and never walk through // the next row or past the uploaded buffer. const int sourceX = x - 20; if (sourceX < 0 || sourceX >= 600) continue; const std::size_t source = (static_cast(y) * 600U + static_cast(sourceX)) * 4U; if (photoImportRgba[source + 3] < 24) continue; int bestError = std::numeric_limits::max(); std::uint8_t bestIndex = 1; for (int index : kInkPresetIndexes) { const auto& candidate = doc.palette[static_cast(index)]; const int dr = static_cast(candidate.r) - photoImportRgba[source]; const int dg = static_cast(candidate.g) - photoImportRgba[source + 1]; const int db = static_cast(candidate.b) - photoImportRgba[source + 2]; const int error = dr * dr * 3 + dg * dg * 6 + db * db * 2; if (error < bestError) { bestError = error; bestIndex = static_cast(index); } } pixels[static_cast(y) * sppm::kWidth + static_cast(x)] = bestIndex; } } photoImportRow = endRow; if (photoImportRow < sppm::kHeight) return; markDirty(); invalidateFrames(); const bool saved = saveDocument(); std::remove(join(importRoot, "photo-transfer.rgba").c_str()); std::remove(join(importRoot, "photo-transfer.cfg").c_str()); audioUploadServer.finishCompletedUpload(saved); audioUploadStatus = saved ? "Photo added to your Switchnote." : "Photo import could not be saved."; if (saved) showToast("Photo imported to layer " + std::string(1, static_cast('A' + photoImportLayer))); photoImportRgba.clear(); photoImporting = false; endLoading(); } void App::renderModal() { renderingModal = true; fill(0, 0, W, H, SDL_Color{10, 15, 24, 126}); const float progress = std::clamp((SDL_GetTicks() - modalTransitionStart) / 150.0f, 0.0f, 1.0f); const float eased = modalClosing ? progress * progress * progress : 1.0f - (1.0f - progress) * (1.0f - progress) * (1.0f - progress); const bool galleryTerms = modal == Modal::GalleryTerms; const bool updateScreen = modal == Modal::UpdateAvailable; const int x = (galleryTerms || updateScreen) ? 42 : 330; const int w = (galleryTerms || updateScreen) ? 1196 : 620; const int h = (galleryTerms || updateScreen) ? 630 : 460; const int targetY = (galleryTerms || updateScreen) ? 45 : 130; const int y = modalClosing ? targetY - static_cast(eased * (targetY + h)) : -h + static_cast(eased * (targetY + h)); rounded(x, y + 6, w, h, 22, SDL_Color{0, 0, 0, 55}); rounded(x, y, w, h, 22, WHITE); auto title = [&](const std::string& value) { centered(value, x + 24, y + 20, w - 48, 42, 28, INK, true); }; auto dismiss = [&]() { if (pill("Close", x + w - 135, y + h - 64, 108, 42, false, 18)) { if (modal == Modal::Photo || modal == Modal::PhotoChoice || modal == Modal::PhotoUpload) closePhotoCapture(); else if (modal == Modal::Audio) closeAudioMenu(); else if (modal == Modal::Branding) closeBrandingMenu(); else modal = Modal::None; } }; switch (modal) { case Modal::GalleryTerms: { centered("Switchnote Gallery: Alpha Server!", x + 56, y + 64, w - 112, 62, 39, INK, true); text("By using services provided by RexiMemo, you agree to the Terms of Use", x + 128, y + 170, 23, INK); text("and acknowledge the Privacy Policy.", x + 128, y + 204, 23, INK); text("You must be 13 years of age or older to use RexiMemo, including", x + 128, y + 258, 23, INK); text("Switchnote Gallery.", x + 128, y + 292, 23, INK); text("Switchnote/RexiMemo does not sell personal information or use it for", x + 128, y + 346, 23, INK); text("targeted advertising.", x + 128, y + 380, 23, INK); text("Terms:", x + 128, y + 436, 22, INK); text("https://reximemo.net/terms", x + 690, y + 436, 22, INK); text("Privacy:", x + 128, y + 474, 22, INK); text("https://reximemo.net/privacy", x + 690, y + 474, 22, INK); if (actionPill("ui/icons/ButtonBText.png", "Back", x + 415, y + 548, 170, 54, false, true, "cursor_cancel")) { modal = Modal::None; screen = Screen::Library; } if (actionPill("ui/icons/ButtonAGalleryText.png", "I agree", x + 610, y + 548, 190, 54, true, true, "cursor_select")) { galleryTermsAccepted = true; std::ofstream accepted(join(dataRoot, "gallery-terms-accepted"), std::ios::binary | std::ios::trunc); if (accepted) accepted << "1\n"; modal = Modal::None; } break; } case Modal::Credits: title("Credits"); centered("Homebrew application by: RexiMemo!", x + 36, y + 120, w - 72, 36, 20, INK); centered("Testing during development: meulin, LatteWiiU", x + 36, y + 180, w - 72, 36, 20, INK); centered("UI Design originally by: Jaames", x + 36, y + 240, w - 72, 36, 20, INK); dismiss(); break; case Modal::GalleryPostChoice: title("Post to Gallery"); centered("Choose whether other users can edit this Switchnote.", x + 34, y + 116, w - 68, 54, 18, INK); centered("A locked Switchnote stays read-only when downloaded.", x + 34, y + 166, w - 68, 42, 16, GRAY); if (actionPill("ui/icons/ButtonXText.png", "Post with Lock", x + 58, y + 258, 240, 56, false, true, "cursor_select")) { modal = Modal::None; performGalleryUpload(pendingGalleryPublishIndex, true); } if (actionPill("ui/icons/ButtonYText.png", "Post without Lock", x + 322, y + 258, 240, 56, false, true, "cursor_select")) { modal = Modal::None; performGalleryUpload(pendingGalleryPublishIndex, false); } if (actionPill("ui/icons/ButtonBText.png", "Cancel", x + 226, y + 350, 168, 52, false, true, "cursor_cancel")) modal = Modal::None; break; case Modal::GalleryNotice: { title("Leaving " + appDisplayName); centered("You are now leaving " + appDisplayName + ".", x + 42, y + 112, w - 84, 38, 20, INK); centered("The following services are provided by", x + 42, y + 165, w - 84, 34, 19, GRAY); centered("RexiMemo.net.", x + 42, y + 201, w - 84, 34, 20, INK, true); if (actionPill("ui/icons/ButtonBText.png", "Back", x + 95, y + h - 82, 170, 54, false, true, "cursor_cancel")) { modal = Modal::None; } if (actionPill("ui/icons/ButtonAText.png", "OK", x + 355, y + h - 82, 170, 54, false, true, "cursor_select")) { confirmGalleryLeave(); } break; } case Modal::GalleryOffline: { title("No Internet Connection"); centered("You don't have an active internet connection.", x + 38, y + 118, w - 76, 34, 18, INK); const std::string before = "Please "; const std::string emphasized = "safely"; const std::string after = " connect your Nintendo Switch"; TTF_Font* bodyFont = font(18, false); int beforeWidth = 0, emphasizedWidth = 0, afterWidth = 0, ignoredHeight = 0; if (bodyFont) { TTF_SizeUTF8(bodyFont, before.c_str(), &beforeWidth, &ignoredHeight); TTF_SizeUTF8(bodyFont, emphasized.c_str(), &emphasizedWidth, &ignoredHeight); TTF_SizeUTF8(bodyFont, after.c_str(), &afterWidth, &ignoredHeight); } const int lineWidth = beforeWidth + emphasizedWidth + afterWidth; const int lineX = x + (w - lineWidth) / 2; rawText(before, lineX, y + 177, 18, INK, false); rawText(emphasized, lineX + beforeWidth, y + 177, 18, INK, false); rawText(emphasized, lineX + beforeWidth + 1, y + 177, 18, INK, false); rawText(after, lineX + beforeWidth + emphasizedWidth, y + 177, 18, INK, false); centered("to the internet, then try again.", x + 38, y + 207, w - 76, 34, 18, INK); if (actionPill("ui/icons/ButtonBText.png", "Back", x + 95, y + h - 82, 170, 54, false, true, "cursor_cancel")) { modal = Modal::None; } if (actionPill("ui/icons/ButtonAText.png", "OK", x + 355, y + h - 82, 170, 54, false, true, "cursor_select")) { modal = Modal::None; } break; } case Modal::Folder: { title("Folder Options"); std::vector folders; if (DIR* dir = opendir(notesRoot.c_str())) { while (dirent* ent = readdir(dir)) { const std::string name = ent->d_name; if (name.empty() || name[0] == '.') continue; const std::string full = join(notesRoot, name); struct stat st{}; if (stat(full.c_str(), &st) == 0 && (st.st_mode & S_IFDIR)) folders.push_back(name); } closedir(dir); } std::sort(folders.begin(), folders.end()); for (std::size_t i = 0; i < folders.size() && i < 6; ++i) { const int by = y + 92 + static_cast(i) * 50; if (pill(folders[i], x + 52, by, w - 104, 42, folders[i] == folder || static_cast(i) == modalSelection, 19, true, "folder_open")) { folder = folders[i]; selected = 0; scanLibrary(); modal = Modal::None; } } if (pill("New Folder +", x + 70, y + h - 67, 220, 42, false, 19)) { modal = Modal::None; beginText(Field::FolderName, ""); } dismiss(); break; } case Modal::Audio: { title("Sound"); rounded(x + 40, y + 75, w - 80, 75, 12, LIGHT); if (serverStartRunning && serverStartTask == ServerStartTask::Audio) { centered("Starting the local sound server…", x + 52, y + 84, w - 104, 54, 18, GRAY); } else if (audioUploadServer.isRunning()) { text(audioUploadUrl, x + 53, y + 82, 18, INK); if (audioUploadServer.isConnected()) text("Connected", x + 53, y + 111, 22, GREEN, true); else text("Code " + audioUploadCode, x + 53, y + 111, 22, ORANGE, true); } else { centered(audioUploadStatus, x + 52, y + 83, w - 104, 56, 17, GRAY); } for (int i = 0; i < 4; ++i) { const int bx = x + 46 + i * 132; if (pill(i == 0 ? "BGM" : "SE" + std::to_string(i), bx, y + 164, 122, 42, i == audioTrack, 17)) { audioTrack = i; audioEraseArmedTrack = -1; if (audioReady) Mix_HaltChannel(4); } } const bool trackPresent = audioTrack < static_cast(doc.audioTracks.size()) && !doc.audioTracks[static_cast(audioTrack)].pcm.empty(); const sppm::AudioTrack* selectedTrack = trackPresent ? &doc.audioTracks[static_cast(audioTrack)] : nullptr; const double seconds = selectedTrack && selectedTrack->channels && selectedTrack->sampleRate ? selectedTrack->pcm.size() / static_cast(selectedTrack->channels * selectedTrack->sampleRate) : 0.0; const std::string durationText = std::to_string(static_cast(std::lround(seconds * 10)) / 10) + "." + std::to_string(static_cast(std::lround(seconds * 10)) % 10) + " s"; text(trackPresent ? "Audio: " + durationText : "No audio in this track", x + 53, y + 221, 18, trackPresent ? INK : GRAY); if (audioTrack == 0) { text("Music timing", x + 53, y + 255, 17, GRAY); if (pill("Attached to speed", x + 48, y + 282, 241, 43, doc.metadata.bgmFollowsSpeed, 16)) { if (!doc.metadata.bgmFollowsSpeed) { doc.metadata.bgmFollowsSpeed = true; markDirty(); rebuildAudio(); } } if (pill("Loose timing", x + 312, y + 282, 241, 43, !doc.metadata.bgmFollowsSpeed, 16)) { if (doc.metadata.bgmFollowsSpeed) { doc.metadata.bgmFollowsSpeed = false; markDirty(); rebuildAudio(); } } } else { bool cueHere = false; if (audioTrack < static_cast(doc.audioTracks.size())) { const auto& cues = doc.audioTracks[static_cast(audioTrack)].frameTriggers; cueHere = std::find(cues.begin(), cues.end(), static_cast(frame)) != cues.end(); } text("Frame " + std::to_string(frame + 1) + " sound effect", x + 53, y + 255, 17, GRAY); if (pill(cueHere ? "Cue on this frame: On" : "Cue on this frame: Off", x + 48, y + 282, 260, 43, cueHere, 16)) { while (doc.audioTracks.size() <= static_cast(audioTrack)) { sppm::AudioTrack blank; blank.name = doc.audioTracks.empty() ? "BGM" : "SE" + std::to_string(doc.audioTracks.size()); blank.sampleRate = 48000; blank.channels = 2; doc.audioTracks.push_back(std::move(blank)); } auto& cues = doc.audioTracks[static_cast(audioTrack)].frameTriggers; const auto cue = static_cast(frame); auto it = std::lower_bound(cues.begin(), cues.end(), cue); if (it != cues.end() && *it == cue) cues.erase(it); else cues.insert(it, cue); markDirty(); } text("SE stays at its recorded speed.", x + 329, y + 293, 15, GRAY); } if (actionPill("ui/icons/Play.png", audioReady && Mix_Playing(4) ? "Stop" : "Preview", x + 48, y + 342, 180, 42, false, trackPresent && audioReady && trackChunks[static_cast(audioTrack)], "")) { if (Mix_Playing(4)) Mix_HaltChannel(4); else if (trackChunks[static_cast(audioTrack)]) Mix_PlayChannel(4, trackChunks[static_cast(audioTrack)], 0); } if (!importFiles.empty()) { const std::string& filename = importFiles[importSelection % importFiles.size()]; centered(filename, x + 245, y + 342, 225, 42, 14, INK); if (pill("<", x + 475, y + 342, 38, 42, false, 17)) importSelection = importSelection ? importSelection - 1 : importFiles.size() - 1; if (pill(">", x + 518, y + 342, 38, 42, false, 17)) importSelection = (importSelection + 1) % importFiles.size(); } if (audioEraseArmedTrack == audioTrack) { if (dangerPill("Confirm erase", x + 48, y + 405, 145, 42, 15, trackPresent)) { if (audioReady) Mix_HaltChannel(4); auto& removed = doc.audioTracks[static_cast(audioTrack)]; removed.pcm.clear(); removed.frameTriggers.clear(); removed.referenceFps = 0; removed.referenceSpeed = 0; markDirty(); rebuildAudio(); saveDocument(true); audioEraseArmedTrack = -1; } } else if (pill("Erase track", x + 48, y + 405, 145, 42, false, 16, trackPresent)) { audioEraseArmedTrack = audioTrack; } if (pill("Import SD WAV", x + 208, y + 405, 195, 42, false, 16, !importFiles.empty())) { audioEraseArmedTrack = -1; importAudio(); } if (pill("Close", x + 478, y + 405, 108, 42, false, 18, !(serverStartRunning && serverStartTask == ServerStartTask::Audio))) closeAudioMenu(); break; } case Modal::Branding: { title("Branding Config Portal"); rounded(x + 40, y + 92, w - 80, 105, 12, LIGHT); if (serverStartRunning && serverStartTask == ServerStartTask::Branding) { centered("Starting the branding portal…", x + 56, y + 113, w - 112, 56, 18, GRAY); } else if (brandingConfigServer.isRunning()) { text(brandingUrl, x + 58, y + 108, 21, INK); if (brandingConfigServer.isConnected()) text("Connected", x + 58, y + 150, 22, GREEN, true); else text("Code " + brandingCode, x + 58, y + 150, 22, ORANGE, true); } else centered(brandingStatus, x + 56, y + 111, w - 112, 68, 18, GRAY); centered("Open the address on your PC, then enter the code", x + 56, y + 225, w - 112, 32, 18, INK); centered("to change the app name, logo, and Switchnote names.", x + 56, y + 256, w - 112, 32, 18, INK); centered("Restart the app before branding changes appear.", x + 56, y + 327, w - 112, 42, 16, GRAY); if (pill("Close", x + w - 135, y + h - 64, 108, 42, false, 18, !(serverStartRunning && serverStartTask == ServerStartTask::Branding))) closeBrandingMenu(); break; } case Modal::Export: { title("Export Switchnote"); text("Choose where to export this Switchnote.", x + 60, y + 100, 20, INK); if (pill("Export to SD:\\Switchnotes · MP4", x + 74, y + 151, 465, 58, modalSelection == 0, 20)) { modal = Modal::None; exportDocument(0); } if (pill("Export to System Captures", x + 74, y + 226, 465, 58, modalSelection == 1, 20)) { modal = Modal::None; exportDocument(1); } text("MP4 export includes animation only; audio is not included.", x + 60, y + 306, 17, GRAY); dismiss(); break; } case Modal::ConfirmDelete: title("Delete this Switchnote?"); centered("This action cannot be undone.", x + 25, y + 126, w - 50, 38, 20, GRAY); if (actionPill("ui/icons/ButtonBText.png", "Cancel", x + 108, y + 234, 160, 52, false, true, "cursor_cancel")) modal = Modal::None; if (dangerActionPill("ui/icons/ButtonZRText.png", "Delete", x + 342, y + 234, 160, 52, 19)) deleteDocument(); break; case Modal::ConfirmGalleryDelete: title("Remove from Switchnote Gallery?"); centered("Only the original poster can remove this Switchnote.", x + 25, y + 126, w - 50, 38, 18, GRAY); if (pill("Cancel", x + 108, y + 234, 160, 52, modalSelection == 0, 20)) modal = Modal::None; if (dangerPill("Remove", x + 342, y + 234, 160, 52, 20)) deleteGalleryItem(); break; case Modal::Options: { title("Editor Options"); auto setSpeed = [&](int speed) { defaultSpeed = speed; defaultFps = kSpeedFps[static_cast(speed - 1)]; if (docLoaded) { doc.metadata.fps = static_cast(defaultFps); doc.metadata.speed = static_cast(speed); markDirty(); if (doc.metadata.bgmFollowsSpeed) rebuildAudio(); } savePreferences(); }; text("Animation speed", x + 60, y + 88, 18, GRAY); const int currentSpeed = docLoaded ? documentSpeed(doc.metadata) : defaultSpeed; for (int speed = 1; speed <= 8; ++speed) { const int bx = x + 58 + (speed - 1) * 64; if (pill(std::to_string(speed), bx, y + 118, 54, 46, currentSpeed == speed, 20)) setSpeed(speed); } if (pill(std::string("Onion skin: ") + (onion ? "On" : "Off"), x + 58, y + 202, 220, 50, onion, 18)) { onion = !onion; savePreferences(); } if (pill(std::string("Canvas grid: ") + (canvasGrid ? "On" : "Off"), x + 312, y + 202, 220, 50, canvasGrid, 18)) { canvasGrid = !canvasGrid; savePreferences(); } if (pill(std::string("Copy to new page: ") + (copyToNewPage ? "On" : "Off"), x + 58, y + 274, 260, 48, copyToNewPage, 17)) { copyToNewPage = !copyToNewPage; savePreferences(); } if (pill(std::string("Loop: ") + (docLoaded && doc.metadata.loopPlayback ? "On" : "Off"), x + 312, y + 274, 220, 48, docLoaded && doc.metadata.loopPlayback, 18, docLoaded)) { doc.metadata.loopPlayback = !doc.metadata.loopPlayback; markDirty(); } if (pill(std::string("Allow spin-offs: ") + (docLoaded && doc.metadata.allowSpinoffs ? "On" : "Off"), x + 58, y + 338, 260, 48, docLoaded && doc.metadata.allowSpinoffs, 17, docLoaded)) { doc.metadata.allowSpinoffs = !doc.metadata.allowSpinoffs; markDirty(); } dismiss(); break; } case Modal::PhotoProbe: { title("Add a Photo"); centered("Checking for an IR camera…", x + 52, y + 155, w - 104, 68, 20, GRAY); break; } case Modal::PhotoChoice: { title("Add a Photo"); centered("Choose how to add an image to this Switchnote.", x + 55, y + 88, w - 110, 36, 18, GRAY); if (pill("Import Image from Album", x + 80, y + 135, 460, 58, false, 20)) openPhotoAlbum(); if (pill("Upload from Another Device", x + 80, y + 207, 460, 58, false, 20)) { openPhotoUpload(); photoModalTransitionDim = true; photoModalTransitionDimAt = SDL_GetTicks(); } #ifdef __SWITCH__ if (irCameraRunning && pill("Take Photo with IR Camera", x + 80, y + 279, 460, 58, false, 20)) { modal = Modal::Photo; modalTransitionStart = SDL_GetTicks(); } #endif if (pill("Cancel", x + 440, y + 377, 120, 48, false, 16)) closePhotoCapture(); break; } case Modal::PhotoAlbum: { title("Import Image from Album"); #ifdef __SWITCH__ const std::size_t pageStart = static_cast(photoAlbumPage) * photoAlbumThumbs.size(); if (R_SUCCEEDED(capsaInitialize())) { for (std::size_t slot = 0; slot < photoAlbumThumbs.size(); ++slot) { const std::size_t index = pageStart + slot; if (photoAlbumThumbIndexes[slot] == index || index >= photoAlbumEntries.size()) continue; if (photoAlbumThumbs[slot]) SDL_DestroyTexture(photoAlbumThumbs[slot]); photoAlbumThumbs[slot] = nullptr; std::vector jpeg(1024U * 1024U); u64 bytes = 0; if (R_SUCCEEDED(capsaLoadAlbumFileThumbnail(&photoAlbumEntries[index].file_id, &bytes, jpeg.data(), jpeg.size())) && bytes > 0 && bytes <= jpeg.size()) { SDL_RWops* thumbStream = SDL_RWFromConstMem(jpeg.data(), static_cast(bytes)); SDL_Surface* thumbSurface = thumbStream ? IMG_Load_RW(thumbStream, 1) : nullptr; if (thumbSurface) { photoAlbumThumbs[slot] = SDL_CreateTextureFromSurface(renderer, thumbSurface); SDL_FreeSurface(thumbSurface); } } photoAlbumThumbIndexes[slot] = index; } capsaExit(); } if (photoAlbumEntries.empty()) centered("No screenshots in the album.", x + 45, y + 150, w - 90, 46, 19, GRAY); for (std::size_t slot = 0; slot < photoAlbumThumbs.size(); ++slot) { const std::size_t index = pageStart + slot; if (index >= photoAlbumEntries.size()) break; const int col = static_cast(slot % 3), row = static_cast(slot / 3); const int tx = x + 35 + col * 182, ty = y + 82 + row * 124; rounded(tx - 2, ty - 2, 168, 104, 10, SDL_Color{210, 214, 220, 255}); rounded(tx, ty, 164, 100, 8, WHITE); if (photoAlbumThumbs[slot]) drawTexture(photoAlbumThumbs[slot], tx + 2, ty + 2, 160, 96); else centered("Image", tx + 2, ty + 25, 160, 40, 17, GRAY); if (hit(tx - 3, ty - 3, 170, 106)) importPhotoAlbumEntry(index); } const int pages = std::max(1, static_cast((photoAlbumEntries.size() + photoAlbumThumbs.size() - 1) / photoAlbumThumbs.size())); if (photoAlbumPage > 0 && pill("<", x + 35, y + 350, 64, 44, false, 22)) { --photoAlbumPage; photoAlbumThumbIndexes.fill(std::numeric_limits::max()); } centered(std::to_string(photoAlbumPage + 1) + " / " + std::to_string(pages), x + 225, y + 350, 170, 44, 17, GRAY); if (photoAlbumPage + 1 < pages && pill(">", x + 521, y + 350, 64, 44, false, 22)) { ++photoAlbumPage; photoAlbumThumbIndexes.fill(std::numeric_limits::max()); } #else centered("The Switch photo album is only available on Nintendo Switch.", x + 45, y + 150, w - 90, 56, 18, GRAY); #endif break; } case Modal::PhotoUpload: { title("Upload Photo"); const bool photoServerStarting = serverStartRunning && serverStartTask == ServerStartTask::Photo; if (photoServerStarting) centered("Starting the local photo server…", x + 42, y + 108, w - 84, 40, 18, GRAY); else { if (!audioUploadUrl.empty()) centered(audioUploadUrl, x + 42, y + 94, w - 84, 36, 19, INK); if (audioUploadServer.isConnected()) centered("Connected", x + 70, y + 139, 200, 34, 19, GREEN, true); else centered("Code " + audioUploadCode, x + 70, y + 139, 300, 34, 19, ORANGE, true); } centered("Open this address on your PC to choose an image and set", x + 42, y + 202, w - 84, 28, 16, GRAY); centered("size, position, layer, color mode and dithering.", x + 42, y + 229, w - 84, 28, 16, GRAY); centered(photoServerStarting ? "Preparing transfer page…" : audioUploadStatus, x + 42, y + 281, w - 84, 32, 17, INK); if (pill("Close", x + 440, y + 372, 120, 48, false, 16, !photoServerStarting)) closePhotoCapture(); break; } case Modal::Photo: { title("IR Photo Import"); #ifdef __SWITCH__ if (irPhotoTaken || irImageReady) { if (irPreviewTexture) { SDL_Rect previewRect{x + 150, y + 78, 320, 240}; SDL_RenderCopyEx(renderer, irPreviewTexture, nullptr, &previewRect, irRotatePhoto ? 90.0 : 0.0, nullptr, SDL_FLIP_NONE); } else centered("Camera preview unavailable", x + 50, y + 100, w - 100, 180, 20, GRAY); if (!irRotatePhoto) centered("Photo replaces the selected layer. Undo is available.", x + 45, y + 322, w - 90, 30, 15, GRAY); } else centered(irsReady ? "Starting IR camera…" : "Connect a Right Joy-Con with an IR camera.", x + 50, y + 112, w - 100, 150, 19, INK); if (!irPhotoTaken) { if (pill(irImageReady ? "Take Photo" : "Waiting…", x + 40, y + 372, 120, 48, false, 16, irImageReady)) { irPhoto = irImage; irPhotoTaken = true; for (std::size_t i = 0; i < irPhoto.size(); ++i) { irPreviewRgba[i * 4] = irPhoto[i]; irPreviewRgba[i * 4 + 1] = irPhoto[i]; irPreviewRgba[i * 4 + 2] = irPhoto[i]; } if (irPreviewTexture) SDL_UpdateTexture(irPreviewTexture, nullptr, irPreviewRgba.data(), 320 * 4); } if (pill(std::string("Rotate: ") + (irRotatePhoto ? "On" : "Off"), x + 170, y + 372, 120, 48, irRotatePhoto, 16)) irRotatePhoto = !irRotatePhoto; } else { if (pill("Retake", x + 40, y + 372, 120, 48, false, 16)) irPhotoTaken = false; if (pill(std::string("Rotate: ") + (irRotatePhoto ? "On" : "Off"), x + 170, y + 372, 120, 48, irRotatePhoto, 16)) irRotatePhoto = !irRotatePhoto; if (pill("Use Photo", x + 300, y + 372, 120, 48, true, 16)) importPhoto(); } #else centered("Photo capture is available on Nintendo Switch.", x + 50, y + 125, w - 100, 130, 19, INK); #endif if (pill("Cancel", x + 440, y + 372, 120, 48, false, 16)) closePhotoCapture(); break; } case Modal::Help: title("Controls"); text("Touch / mouse", x + 52, y + 90, 19, ORANGE, true); text("Draw on the canvas, select tools, colors and frames.", x + 52, y + 121, 17, INK); text("A / Enter Select or play", x + 52, y + 164, 18, INK); text("B / Esc Back", x + 52, y + 200, 18, INK); text("Y Edit selected Switchnote", x + 52, y + 236, 18, INK); text("L / R Previous / next layer", x + 52, y + 272, 18, INK); text("ZL / ZR Undo / redo", x + 52, y + 308, 18, INK); dismiss(); break; case Modal::UpdateAvailable: { rounded(x, y + 7, w, h, 24, SDL_Color{0, 0, 0, 45}); rounded(x, y, w, h, 24, UPDATE_ORANGE); roundedOutline(x, y, w, h, 24, 2, WHITE); centered("Update Available", x + 36, y + 48, w - 72, 62, 39, WHITE); text("A new update is available for " + appDisplayName + ":", x + 134, y + 194, 27, WHITE); centered(appDisplayName + ", " + updateServerVersion, x + 100, y + 276, w - 200, 42, 27, WHITE); text("Author:", x + 134, y + 344, 26, WHITE); text("Version:", x + 134, y + 373, 26, WHITE); text("File SHA256:", x + 134, y + 402, 26, WHITE); text("RexiMemo!", x + 922, y + 344, 26, WHITE); text(updateServerVersion, x + 946, y + 373, 26, WHITE); text(updateSha256.empty() ? "Not provided by update service" : updateSha256, x + 448, y + 410, updateSha256.empty() ? 18 : 15, WHITE); text("Please run the app from the Homebrew Menu in App Override mode, or", x + 134, y + 462, 25, WHITE); text("install the official NSP file.", x + 134, y + 491, 25, WHITE); if (pill("Update", x + 528, y + 548, 140, 50, true, 26, true, "cursor_select")) startUpdateDownload(); break; } case Modal::TextInput: title(editingField == Field::Title ? "Switchnote Title" : editingField == Field::Description ? "Description" : editingField == Field::FolderName ? "New Folder" : editingField == Field::GalleryBio ? "Gallery Bio" : "Comment"); rounded(x + 44, y + 118, w - 88, 64, 12, LIGHT); text(inputText + "▏", x + 60, y + 132, 21, INK); if (pill("Cancel", x + 92, y + 244, 160, 48, false, 19)) { modal = Modal::None; editingField = Field::None; SDL_StopTextInput(); } if (pill("Done", x + 365, y + 244, 160, 48, false, 19)) applyText(); break; case Modal::None: break; } renderingModal = false; } bool switchnoteBrushPattern(int id, int x, int y) { switch (id & 7) { case 0: return x % 3 == 1 && y % 3 == 1; case 1: return (x & 1) == 0 && (y & 1) == 0; case 2: return (x & 1) == 1; case 3: return (y & 1) == 1; case 4: return ((x + y) & 1) == 1; case 5: return (x & 1) != 0 || (y & 1) != 0; case 6: return !(x % 3 == 1 && y % 3 == 1); case 7: return ((x - y) % 5 == 0) || ((x + y) % 5 == 0); default: return false; } } bool switchnoteEraserContains(int slot, int dx, int dy) { switch (slot & 7) { case 0: return dx == 0 && dy == 0; case 1: return std::abs(dx) <= 1 && std::abs(dy) <= 1; case 2: return dx * dx + dy * dy <= 12; case 3: return dx * dx + dy * dy <= 42; case 4: return std::abs(dx) <= 4 && std::abs(dy) <= 4; case 5: return std::abs(dx) <= 7 && std::abs(dy) <= 7; case 6: return std::abs(dx) <= 1 && std::abs(dy) <= 6; case 7: return std::abs(dy) <= 1 && std::abs(dx) <= 6; default: return false; } } bool App::paintPixel(int sx, int sy, std::vector& pixels) { const float scale = screen == Screen::CommentCanvas ? 2.0f : 2.0f * zoom; const int width = static_cast(sppm::kWidth * scale); const int left = screen == Screen::CommentCanvas ? 0 : (W - width) / 2 + panX; const int height = static_cast(sppm::kHeight * scale); const int top = screen == Screen::CommentCanvas ? 0 : (H - height) / 2 + panY; // Use the same rounded destination dimensions as the renderer. Dividing // by the requested float scale drifted by a pixel at fractional zoom. const float pixelWidth = width / static_cast(sppm::kWidth); const float pixelHeight = height / static_cast(sppm::kHeight); const int px = static_cast(std::floor((sx - left) / pixelWidth)); const int py = static_cast(std::floor((sy - top) / pixelHeight)); if (px < 0 || py < 0 || px >= sppm::kWidth || py >= sppm::kHeight) return false; int previousPx = px, previousPy = py; if (hasLastStroke) { previousPx = static_cast(std::floor((lastStrokeX - left) / pixelWidth)); previousPy = static_cast(std::floor((lastStrokeY - top) / pixelHeight)); } const int motionX = hasLastStroke ? px - previousPx : 0; const int motionY = hasLastStroke ? py - previousPy : 0; const bool usePattern = screen != Screen::CommentCanvas && activeTool == Tool::Brush; const bool erase = screen != Screen::CommentCanvas && activeTool == Tool::Eraser; const bool usePen = screen != Screen::CommentCanvas && activeTool == Tool::Pen; bool changed = false; const std::uint8_t value = screen == Screen::CommentCanvas ? 1 : (erase ? 0 : static_cast(std::clamp(color, 1, 255))); auto write = [&](int x, int y) { if (x < 0 || y < 0 || x >= sppm::kWidth || y >= sppm::kHeight) return; const auto index = static_cast(y) * sppm::kWidth + x; if (pixels[index] != value) { pixels[index] = value; changed = true; } }; auto clearInk = [&](int x, int y) { if (x < 0 || y < 0 || x >= sppm::kWidth || y >= sppm::kHeight) return; const auto index = static_cast(y) * sppm::kWidth + x; if (pixels[index] != 0) { pixels[index] = 0; changed = true; } }; auto writePen = [&](int x, int y) { const int size = std::clamp(brush, 1, 32); const int start = -((size - 1) / 2); const float center = static_cast(start) + (size - 1) / 2.0f; const float radius = std::max(0.5f, size / 2.0f); for (int oy = 0; oy < size; ++oy) for (int ox = 0; ox < size; ++ox) { const int dx = start + ox, dy = start + oy; const float ddx = dx - center, ddy = dy - center; if (!square && ddx * ddx + ddy * ddy > radius * radius) continue; write(x + dx, y + dy); } }; auto stampSquare = [&](int x, int y, int radius) { for (int dy = -radius; dy <= radius; ++dy) for (int dx = -radius; dx <= radius; ++dx) writePen(x + dx, y + dy); }; if (usePattern) { // Match the web editor's eight paint predicates on absolute canvas // coordinates. Normal paint is a 3x3 mask; Advanced is a 15x15 mask. const int radius = advancedBrush ? 7 : 1; for (int dy = -radius; dy <= radius; ++dy) for (int dx = -radius; dx <= radius; ++dx) if (switchnoteBrushPattern(pattern, px + dx, py + dy)) write(px + dx, py + dy); } else if (erase) { static constexpr std::array radii{{0, 1, 3, 6, 4, 7, 6, 6}}; const int radius = radii[static_cast(eraserSlot & 7)]; for (int dy = -radius; dy <= radius; ++dy) for (int dx = -radius; dx <= radius; ++dx) if (switchnoteEraserContains(eraserSlot, dx, dy)) write(px + dx, py + dy); } else if (!usePen) { const int size = std::clamp(brush, 1, 32); const int start = -((size - 1) / 2); const float center = static_cast(start) + (size - 1) / 2.0f; const float radius = std::max(0.5f, size / 2.0f); for (int oy = 0; oy < size; ++oy) for (int ox = 0; ox < size; ++ox) { const int dx = start + ox, dy = start + oy; const float ddx = dx - center, ddy = dy - center; if (!square && ddx * ddx + ddy * ddy > radius * radius) continue; write(px + dx, py + dy); } } else { auto random16 = [&]() -> std::uint16_t { const std::uint16_t old = penRngState; const std::uint16_t bit0 = old & 1u; const std::uint16_t bit3 = (old >> 3) & 1u; penRngState = old >> 1; if (bit0 == bit3) penRngState = static_cast(penRngState | 0x8000u); return penRngState; }; auto randomRange = [&](int range) { return static_cast(random16() % static_cast(std::max(1, range))); }; auto drawLine = [&](int x0, int y0, int x1, int y1, bool includeStart) { int x = x0, y = y0; const int dx = std::abs(x1 - x), stepX = x < x1 ? 1 : -1; const int dyAbs = std::abs(y1 - y), stepY = y < y1 ? 1 : -1; int error = dx - dyAbs; bool first = true; while (true) { if (includeStart || !first) writePen(x, y); if (x == x1 && y == y1) break; first = false; const int twice = 2 * error; if (twice > -dyAbs) { error -= dyAbs; x += stepX; } if (twice < dx) { error += dx; y += stepY; } } }; auto stampDirectionAware = [&]() { writePen(px, py); if (motionX || motionY) { writePen(px + motionY, py); writePen(px, py + motionX); } }; auto stampDirectionalEqual = [&]() { const bool horizontal = motionX != 0 && motionY == 0; const bool vertical = motionY != 0 && motionX == 0; if (horizontal) { writePen(px, py - 1); writePen(px, py + 1); } else if (vertical) { writePen(px - 1, py); writePen(px + 1, py); } else if (motionX == motionY) { writePen(px - 1, py); writePen(px + 1, py); } else { writePen(px, py - 1); writePen(px, py + 1); } clearInk(px, py); if (std::abs(motionX) >= std::abs(motionY)) writePen(px, py + (motionX ? motionX : 1)); else writePen(px + (motionY ? motionY : 1), py); }; auto randomOffset = [&](bool wide) { return randomRange(wide ? 10 : 5) - (wide ? 5 : 2); }; auto stampJitter = [&](bool wide) { const int ax = std::clamp(previousPx + randomOffset(wide), 0, sppm::kWidth - 1); const int ay = std::clamp(previousPy + randomOffset(wide), 0, sppm::kHeight - 1); const int bx = std::clamp(px + randomOffset(wide), 0, sppm::kWidth - 1); const int by = std::clamp(py + randomOffset(wide), 0, sppm::kHeight - 1); drawLine(ax, ay, bx, by, true); }; auto sprayClass = [&]() { const int d2 = motionX * motionX + motionY * motionY; if (d2 < 4) return 6; if (d2 < 8) return 5; if (d2 < 12) return 4; if (d2 < 24) return 3; if (d2 < 48) return 2; return 1; }; auto stampSpray = [&](bool heavy) { const int cls = sprayClass(); const int count = cls + (heavy ? 3 : 2); const int spread = (7 - cls) + (heavy ? 5 : 2); for (int i = 0; i < count; ++i) { const int scale = heavy && (i & 1) ? 2 : 1; const int ax = std::clamp(previousPx + (randomRange(spread * 2 + 1) - spread) * scale, 0, sppm::kWidth - 1); const int ay = std::clamp(previousPy + (randomRange(spread * 2 + 1) - spread) * scale, 0, sppm::kHeight - 1); const int bx = std::clamp(px + (randomRange(spread * 2 + 1) - spread) * scale, 0, sppm::kWidth - 1); const int by = std::clamp(py + (randomRange(spread * 2 + 1) - spread) * scale, 0, sppm::kHeight - 1); drawLine(ax, ay, bx, by, true); } }; auto stampAirbrush = [&](bool large) { static const std::array smallMask{{"..#..", ".###.", "#####", ".###.", "..#.."}}; static const std::array largeMask{{ "......###......", ".....#####.....", "...#########...", "..###########..", ".#############.", "###############", "###############", "###############", "###############", "###############", ".#############.", "..###########..", "...#########...", ".....#####.....", "......###......" }}; const int width = large ? 15 : 5; const int height = large ? 15 : 5; const int segmentMagnitude = std::max(std::abs(motionX), std::abs(motionY)); const int base = large ? 72 : 98; const int penalty = std::min(38, std::max(0, segmentMagnitude - 1) * (large ? 2 : 4)); const int threshold = std::max(24, base - penalty); for (int row = 0; row < height; ++row) for (int col = 0; col < width; ++col) { const char mark = large ? largeMask[static_cast(row)][col] : smallMask[static_cast(row)][col]; if (mark != '#' || static_cast(random16() & 0xffu) >= threshold) continue; writePen(px + col - width / 2, py + row - height / 2); } }; switch (penType) { case 0: writePen(px, py); break; case 1: stampDirectionAware(); break; case 2: stampSquare(px, py, 1); break; case 3: { const int qx = px & ~1, qy = py & ~1; if (squiggleAnchorX >= 0 && (squiggleAnchorX != qx || squiggleAnchorY != qy)) { const int mx = ((squiggleAnchorX + qx) >> 1) & ~1; const int my = ((squiggleAnchorY + qy) >> 1) & ~1; writePen(mx, my); writePen(mx + 1, my); writePen(mx, my + 1); writePen(mx + 1, my + 1); } writePen(qx, qy); writePen(qx + 1, qy); writePen(qx, qy + 1); writePen(qx + 1, qy + 1); squiggleAnchorX = qx; squiggleAnchorY = qy; break; } case 4: stampDirectionalEqual(); break; case 5: stampAirbrush(false); break; case 6: if (motionX || motionY) stampJitter(false); else writePen(px, py); break; case 7: if (motionX || motionY) stampSpray(false); else writePen(px, py); break; case 8: stampSquare(px, py, 2); break; case 9: stampSquare(px, py, 3); break; case 10: stampSquare(px, py, 4); break; case 11: { const int d2 = motionX * motionX + motionY * motionY; const int cls = d2 < 4 ? 3 : d2 < 32 ? 2 : 1; if (cls == 1) writePen(px, py); else if (cls == 2) stampDirectionAware(); else stampSquare(px, py, 1); break; } case 12: { static const std::array outer{{"..###..", ".#####.", "#######", "#######", "#######", ".#####.", "..###.."}}; static const std::array inner{{"..#..", ".###.", "#####", ".###.", "..#.."}}; for (int y = 0; y < 7; ++y) for (int x = 0; x < 7; ++x) if (outer[static_cast(y)][x] == '#') write(px + x - 3, py + y - 3); const int holeX = ringPreviousX < 0 ? px : ringPreviousX; const int holeY = ringPreviousY < 0 ? py : ringPreviousY; for (int y = 0; y < 5; ++y) for (int x = 0; x < 5; ++x) if (inner[static_cast(y)][x] == '#') clearInk(holeX + x - 2, holeY + y - 2); ringPreviousX = px; ringPreviousY = py; break; } case 13: stampAirbrush(true); break; case 14: if (motionX || motionY) stampJitter(true); else writePen(px, py); break; case 15: if (motionX || motionY) stampSpray(true); else writePen(px, py); break; default: writePen(px, py); break; } } return changed; } void App::strokeAt(int sx, int sy, bool comment) { const bool stabilise = !comment && penStabilisationEnabled && activeTool != Tool::Eraser; if (stabilise && !stabilisedAfterReplay) { if (penStabilisationAfter) { if (afterStrokePoints.size() < 50000) afterStrokePoints.emplace_back(sx, sy); return; } const float amount = penStabilisationStrength / 100.0f; const float follow = 1.0f - amount * 0.88f; if (!realtimeStrokeInitialized) { realtimeStrokeX = static_cast(sx); realtimeStrokeY = static_cast(sy); realtimeStrokeInitialized = true; } else { realtimeStrokeX += (sx - realtimeStrokeX) * follow; realtimeStrokeY += (sy - realtimeStrokeY) * follow; } sx = static_cast(std::lround(realtimeStrokeX)); sy = static_cast(std::lround(realtimeStrokeY)); } std::vector* pixels = nullptr; if (comment) pixels = &commentPixels; else if (docLoaded && frame < doc.frames.size() && layer >= 0 && layer < 4) pixels = &doc.frames[frame].layers[layer]; if (!pixels) return; bool changed = false; if (hasLastStroke) { const int startX = lastStrokeX, startY = lastStrokeY; const int dx = sx - startX, dy = sy - startY; const int count = std::max(std::abs(dx), std::abs(dy)); for (int i = 1; i <= count; ++i) { const int x = startX + dx * i / count; const int y = startY + dy * i / count; changed |= paintPixel(x, y, *pixels); lastStrokeX = x; lastStrokeY = y; } } else { squiggleAnchorX = squiggleAnchorY = ringPreviousX = ringPreviousY = -1; changed = paintPixel(sx, sy, *pixels); lastStrokeX = sx; lastStrokeY = sy; hasLastStroke = true; } if (changed) { if (comment) commentDirty = true; else markDirty(); } } void App::finishAfterStroke() { if (!penStabilisationEnabled || !penStabilisationAfter || afterStrokePoints.empty() || !docLoaded || frame >= doc.frames.size() || layer < 0 || layer >= 4) { afterStrokePoints.clear(); realtimeStrokeInitialized = false; return; } const std::size_t strokeFrame = frame; const int strokeLayer = layer; auto& pixels = doc.frames[strokeFrame].layers[static_cast(strokeLayer)]; if (!undoStack.empty() && undoStack.back().frame == strokeFrame && undoStack.back().layer == strokeLayer && undoStack.back().pixels.size() == pixels.size()) { pixels = undoStack.back().pixels; markDirty(); } const auto points = std::move(afterStrokePoints); afterStrokePoints.clear(); const int radius = std::clamp(static_cast(std::lround(penStabilisationStrength * 0.06f)), 0, 6); stabilisedAfterReplay = true; hasLastStroke = false; for (std::size_t i = 0; i < points.size(); ++i) { int x = 0, y = 0, count = 0; const std::size_t from = i > static_cast(radius) ? i - static_cast(radius) : 0; const std::size_t to = std::min(points.size() - 1, i + static_cast(radius)); for (std::size_t j = from; j <= to; ++j) { x += points[j].first; y += points[j].second; ++count; } strokeAt(x / count, y / count); } stabilisedAfterReplay = false; hasLastStroke = false; realtimeStrokeInitialized = false; } } // namespace