7797 lines
377 KiB
C++
7797 lines
377 KiB
C++
#include <SDL.h>
|
||
#include <SDL_image.h>
|
||
#include <SDL_mixer.h>
|
||
#include <SDL_ttf.h>
|
||
|
||
#ifdef __SWITCH__
|
||
#include <switch.h>
|
||
#endif
|
||
|
||
#include "sppm.hpp"
|
||
#include "export.hpp"
|
||
#include "audio_upload_server.hpp"
|
||
#include "branding_config_server.hpp"
|
||
#include "branding_bundle.hpp"
|
||
|
||
#include <algorithm>
|
||
#include <array>
|
||
#include <atomic>
|
||
#include <cerrno>
|
||
#include <cmath>
|
||
#include <cctype>
|
||
#include <cstdint>
|
||
#include <cstdio>
|
||
#include <cstdlib>
|
||
#include <cstring>
|
||
#include <ctime>
|
||
#include <dirent.h>
|
||
#include <fstream>
|
||
#include <map>
|
||
#include <limits>
|
||
#include <random>
|
||
#include <sstream>
|
||
#include <string>
|
||
#include <sys/stat.h>
|
||
#ifdef __SWITCH__
|
||
#include <arpa/inet.h>
|
||
#include <netdb.h>
|
||
#include <netinet/in.h>
|
||
#include <sys/socket.h>
|
||
#include <sys/time.h>
|
||
#include <unistd.h>
|
||
#endif
|
||
#ifdef _WIN32
|
||
#include <direct.h>
|
||
#endif
|
||
#include <unordered_map>
|
||
#include <utility>
|
||
#include <vector>
|
||
|
||
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<int, 10> 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<std::uint64_t> 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<unsigned char>(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<unsigned long>& 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<unsigned>(version[i] - '0');
|
||
if (value > (std::numeric_limits<unsigned long>::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<unsigned long> 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<std::streamsize>(sizeof(magic)) &&
|
||
std::memcmp(magic, "NRO0", sizeof(magic)) == 0;
|
||
}
|
||
|
||
std::string canonicalSdPath(std::string path) {
|
||
while (!path.empty() && std::isspace(static_cast<unsigned char>(path.front()))) path.erase(path.begin());
|
||
while (!path.empty() && std::isspace(static_cast<unsigned char>(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<char>(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<char>(std::tolower(c)); });
|
||
if (name != "retry-after") continue;
|
||
std::string value = line.substr(colon + 1);
|
||
while (!value.empty() && std::isspace(static_cast<unsigned char>(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<float>(kScreenFadeMs), 0.0f, 1.0f);
|
||
return static_cast<Uint8>(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<std::uint8_t> previewBytes;
|
||
};
|
||
|
||
struct GalleryComment {
|
||
std::string author;
|
||
std::string text;
|
||
std::string created;
|
||
std::vector<std::uint8_t> 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<std::uint8_t> avatar;
|
||
std::vector<Item> uploads;
|
||
};
|
||
|
||
struct UndoState {
|
||
std::size_t frame = 0;
|
||
int layer = 0;
|
||
std::vector<std::uint8_t> pixels;
|
||
};
|
||
|
||
struct FrameTexture {
|
||
SDL_Texture* texture = nullptr;
|
||
std::uint64_t revision = 0;
|
||
};
|
||
|
||
const std::array<int, 8> kSpeedFps{{1, 1, 2, 4, 6, 12, 20, 30}};
|
||
const std::array<int, 8> kLegacySpeedFps{{3, 5, 8, 12, 15, 18, 24, 30}};
|
||
const std::array<double, 8> 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<std::size_t>(speed - 1)] : std::max(1, legacyFps);
|
||
}
|
||
|
||
int speedFromFps(int fps) {
|
||
int closest = 0;
|
||
for (int i = 1; i < static_cast<int>(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<char>(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<std::uint8_t>((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<char>((hi << 4) | lo)); i += 2; continue; }
|
||
}
|
||
if (input[i] == '+') out.push_back(' ');
|
||
else out.push_back(input[i]);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
std::vector<std::string> splitTabs(const std::string& line) {
|
||
std::vector<std::string> 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<std::uint8_t>& 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<std::uint8_t> base64Decode(const std::string& input) {
|
||
std::array<int, 256> values{};
|
||
values.fill(-1);
|
||
const std::string alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||
for (int i = 0; i < static_cast<int>(alphabet.size()); ++i) values[static_cast<unsigned char>(alphabet[static_cast<std::size_t>(i)])] = i;
|
||
std::vector<std::uint8_t> 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<std::uint32_t>(value);
|
||
bits += 6;
|
||
if (bits >= 8) {
|
||
bits -= 8;
|
||
out.push_back(static_cast<std::uint8_t>((accumulator >> bits) & 0xFF));
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
int blackInkIndex(const sppm::Palette& palette) {
|
||
int bestIndex = 2;
|
||
int bestDistance = std::numeric_limits<int>::max();
|
||
for (int i = 1; i < static_cast<int>(palette.size()); ++i) {
|
||
const auto& color = palette[static_cast<std::size_t>(i)];
|
||
if (!color.a) continue;
|
||
const int distance = static_cast<int>(color.r) * color.r +
|
||
static_cast<int>(color.g) * color.g +
|
||
static_cast<int>(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<unsigned char>(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<char, 16384> buffer{};
|
||
while (input) {
|
||
input.read(buffer.data(), static_cast<std::streamsize>(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<bool>(complete);
|
||
#else
|
||
(void)destination;
|
||
return true;
|
||
#endif
|
||
}
|
||
|
||
std::uint64_t nowSeconds() { return static_cast<std::uint64_t>(std::time(nullptr)); }
|
||
|
||
std::string dateText(std::uint64_t seconds) {
|
||
if (!seconds) return "Unknown";
|
||
std::time_t value = static_cast<std::time_t>(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<std::uint8_t*>(out);
|
||
for (std::size_t i = 0; i < size; ++i) bytes[i] = static_cast<std::uint8_t>(rd());
|
||
return true;
|
||
} catch (...) {
|
||
return false;
|
||
}
|
||
#endif
|
||
}
|
||
|
||
void appendIdentityMaterial(std::vector<std::uint8_t>& out, std::uint8_t field,
|
||
const std::uint8_t* bytes, std::size_t length) {
|
||
out.push_back(field);
|
||
out.push_back(static_cast<std::uint8_t>(length));
|
||
out.insert(out.end(), bytes, bytes + length);
|
||
}
|
||
|
||
bool getDeviceMaterial(std::vector<std::uint8_t>& 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<const std::uint8_t*>(&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<const std::uint8_t*>(&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<const std::uint8_t*>(machine), std::strlen(machine));
|
||
} else {
|
||
const std::string fallback = "local-development-profile";
|
||
appendIdentityMaterial(material, 4, reinterpret_cast<const std::uint8_t*>(fallback.data()), fallback.size());
|
||
}
|
||
return true;
|
||
#endif
|
||
}
|
||
|
||
bool readBinaryFile(const std::string& path, std::vector<std::uint8_t>& 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<std::size_t>(size));
|
||
input.seekg(0, std::ios::beg);
|
||
if (!bytes.empty()) input.read(reinterpret_cast<char*>(bytes.data()), size);
|
||
return input.good() || (input.eof() && input.gcount() == size);
|
||
}
|
||
|
||
bool loadEncryptedIdentity(const std::string& path,
|
||
const std::vector<std::uint8_t>& deviceMaterial,
|
||
sppm::SigningIdentity& identity) {
|
||
constexpr std::size_t fileBytes = 4 + 16 + 24 + 32 + 16 + 128;
|
||
std::vector<std::uint8_t> bytes;
|
||
if (!readBinaryFile(path, bytes) || bytes.size() != fileBytes ||
|
||
std::memcmp(bytes.data(), "FNI2", 4) != 0) return false;
|
||
std::size_t at = 4;
|
||
std::array<std::uint8_t, 16> salt{};
|
||
std::array<std::uint8_t, 24> nonce{};
|
||
std::array<std::uint8_t, 128> sealed{};
|
||
std::copy_n(bytes.begin() + static_cast<std::ptrdiff_t>(at), salt.size(), salt.begin()); at += salt.size();
|
||
std::copy_n(bytes.begin() + static_cast<std::ptrdiff_t>(at), nonce.size(), nonce.begin()); at += nonce.size();
|
||
std::copy_n(bytes.begin() + static_cast<std::ptrdiff_t>(at), identity.publicIdentity.key.size(), identity.publicIdentity.key.begin()); at += identity.publicIdentity.key.size();
|
||
std::copy_n(bytes.begin() + static_cast<std::ptrdiff_t>(at), identity.publicIdentity.deviceTag.size(), identity.publicIdentity.deviceTag.begin()); at += identity.publicIdentity.deviceTag.size();
|
||
std::copy_n(bytes.begin() + static_cast<std::ptrdiff_t>(at), sealed.size(), sealed.begin());
|
||
std::vector<std::uint8_t> 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<std::uint8_t>& deviceMaterial,
|
||
const std::array<std::uint8_t, 32>& legacyKey,
|
||
sppm::SigningIdentity& identity,
|
||
std::string& pathOut) {
|
||
std::array<std::uint8_t, 16> salt{};
|
||
std::array<std::uint8_t, 24> nonce{};
|
||
if (!randomBytes(salt.data(), salt.size()) || !randomBytes(nonce.data(), nonce.size())) return false;
|
||
std::vector<std::uint8_t> 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<std::uint8_t, 128> 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<std::uint8_t> 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<const char*>(bytes.data()), static_cast<std::streamsize>(bytes.size()));
|
||
return static_cast<bool>(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<bool> photoProbeFinished{false};
|
||
IrsImageTransferProcessorState irImageState{};
|
||
std::vector<std::uint8_t> irImage = std::vector<std::uint8_t>(320 * 240, 0);
|
||
std::vector<std::uint8_t> irPhoto = std::vector<std::uint8_t>(320 * 240, 0);
|
||
std::vector<std::uint8_t> irPreviewRgba = std::vector<std::uint8_t>(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<std::uint8_t> photoImportRgba;
|
||
int photoImportRow = 0;
|
||
int photoImportLayer = 0;
|
||
int photoAlbumPage = 0;
|
||
bool galleryTermsAccepted = false;
|
||
#ifdef __SWITCH__
|
||
std::vector<CapsAlbumEntry> photoAlbumEntries;
|
||
std::array<SDL_Texture*, 6> photoAlbumThumbs{};
|
||
std::array<std::size_t, 6> 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<bool> 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<SDL_Texture*, 8> 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<std::uint8_t> profileImageBytes;
|
||
SDL_Texture* profileTexture = nullptr;
|
||
SDL_Texture* profileCornerTexture = nullptr;
|
||
SDL_Texture* profileDetailTexture = nullptr;
|
||
sppm::SigningIdentity signingIdentity{};
|
||
std::map<std::string, SDL_Texture*> images;
|
||
std::map<std::string, SDL_Texture*> whiteImages;
|
||
std::map<std::pair<int, bool>, TTF_Font*> fonts;
|
||
std::unordered_map<std::string, SDL_Texture*> textCache;
|
||
std::unordered_map<std::uint64_t, float> hoverMotion;
|
||
std::map<std::string, Mix_Chunk*> cues;
|
||
std::array<Mix_Chunk*, 4> trackChunks{};
|
||
Mix_Chunk* bgmSeekChunk = nullptr;
|
||
std::array<std::vector<Uint8>, 4> trackAudioBuffers{};
|
||
std::map<std::size_t, FrameTexture> 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<Item> library;
|
||
std::vector<Item> gallery;
|
||
std::vector<GalleryComment> 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<Item> 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<bool> galleryLoadFinished{false};
|
||
std::string galleryLoadPath;
|
||
GalleryLoadKind galleryLoadKind = GalleryLoadKind::Feed;
|
||
std::vector<std::uint8_t> galleryLoadResponse;
|
||
int galleryLoadHttpStatus = 0;
|
||
bool galleryLoadSucceeded = false;
|
||
std::string galleryLoadError;
|
||
bool serverStartRunning = false;
|
||
std::atomic<bool> 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<std::string, SDL_Texture*> 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<std::pair<int, int>> 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<UndoState> undoStack;
|
||
std::vector<UndoState> redoStack;
|
||
int audioTrack = 0;
|
||
int audioEraseArmedTrack = -1;
|
||
std::vector<std::string> importFiles;
|
||
std::size_t importSelection = 0;
|
||
std::vector<std::uint8_t> commentPixels = std::vector<std::uint8_t>(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<std::pair<std::uint32_t, std::uint16_t>> 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<std::uint8_t>& response, int status, const std::string& error);
|
||
void finishGalleryProfile(const std::vector<std::uint8_t>& 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<std::uint8_t>& requestBody,
|
||
const std::string& contentType,
|
||
std::vector<std::uint8_t>& 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<std::uint8_t>& 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("<empty>") : launchNroPath));
|
||
assetsRoot = "romfs:";
|
||
dataRoot = "sdmc:/switch/Switchnote";
|
||
const Result romfsResult = romfsInit();
|
||
if (R_FAILED(romfsResult)) {
|
||
logStartup("startup: romfsInit failed: " + std::to_string(static_cast<unsigned int>(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<unsigned int>(csrngResult)));
|
||
return false;
|
||
}
|
||
csrngReady = true;
|
||
const Result accountResult = accountInitialize(AccountServiceType_Application);
|
||
logStartup("profile: accountInitialize=" + std::to_string(static_cast<unsigned int>(accountResult)));
|
||
if (R_SUCCEEDED(accountResult)) {
|
||
accountReady = true;
|
||
std::vector<AccountUid> 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<unsigned int>(preselectedResult)));
|
||
uid = {};
|
||
const Result lastOpenedResult = accountGetLastOpenedUser(&uid);
|
||
if (R_SUCCEEDED(lastOpenedResult)) addCandidate(uid);
|
||
logStartup("profile: last-opened-user=" + std::to_string(static_cast<unsigned int>(lastOpenedResult)));
|
||
uid = {};
|
||
const Result implicitResult = accountTrySelectUserWithoutInteraction(&uid, false);
|
||
if (R_SUCCEEDED(implicitResult)) addCandidate(uid);
|
||
logStartup("profile: implicit-user=" + std::to_string(static_cast<unsigned int>(implicitResult)));
|
||
std::array<AccountUid, ACC_USER_LIST_SIZE> 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<std::size_t>(i)]);
|
||
}
|
||
logStartup("profile: user-list=" + std::to_string(static_cast<unsigned int>(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<unsigned int>(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<unsigned int>(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<std::uint8_t> 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<unsigned int>(imageLoadResult)));
|
||
}
|
||
} else {
|
||
logStartup("profile: image-size unavailable candidate=" + std::to_string(candidateIndex) +
|
||
" result=" + std::to_string(static_cast<unsigned int>(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::size_t>(std::numeric_limits<int>::max())) {
|
||
SDL_RWops* stream = SDL_RWFromConstMem(profileImageBytes.data(), static_cast<int>(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<std::uint32_t*>(static_cast<std::uint8_t*>(avatar->pixels) + y * avatar->pitch);
|
||
for (int x = 0; x < avatar->w; ++x) {
|
||
const bool left = x < static_cast<int>(radius);
|
||
const bool right = x >= avatar->w - static_cast<int>(radius);
|
||
const bool top = y < static_cast<int>(radius);
|
||
const bool bottom = y >= avatar->h - static_cast<int>(radius);
|
||
if (!((left || right) && (top || bottom))) continue;
|
||
const float cx = left ? radius : static_cast<float>(avatar->w) - radius;
|
||
const float cy = top ? radius : static_cast<float>(avatar->h) - radius;
|
||
const float dx = static_cast<float>(x) + 0.5f - cx;
|
||
const float dy = static_cast<float>(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<std::uint8_t>(static_cast<float>(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<std::pair<const char*, const char*>, 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<Uint8>(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<std::size_t>((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<int>(std::lround(sourceW * scale)));
|
||
const int drawH = std::max(1, static_cast<int>(std::lround(sourceH * scale)));
|
||
const int x = W - drawW + static_cast<int>(std::lround((1.0f - eased) * drawW));
|
||
drawTexture(frog, x, H - drawH - 14, drawW, drawH);
|
||
}
|
||
}
|
||
|
||
bool App::loadIdentity() {
|
||
std::vector<std::uint8_t> 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<std::uint8_t, 32> legacyKey{};
|
||
const std::string legacyPath = join(dataRoot, "identity.bin");
|
||
std::vector<std::uint8_t> 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<std::uint8_t, 32> idSecret{};
|
||
std::array<std::uint8_t, 16> 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<int>(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<App*>(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<std::uint8_t>& requestBody,
|
||
const std::string& contentType,
|
||
std::vector<std::uint8_t>& 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<unsigned int>(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<unsigned int>(result)) + ")";
|
||
return false;
|
||
}
|
||
gallerySslReady = true;
|
||
}
|
||
std::string authHeaders;
|
||
if ((method == "POST" && path != "/api/flipnotes") || method == "DELETE") {
|
||
std::vector<std::uint8_t> 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<std::uint8_t, 32> nonce{};
|
||
if (!hexDecode(nonceHex, nonce.data(), nonce.size())) {
|
||
error = "Gallery returned an invalid authentication challenge";
|
||
return false;
|
||
}
|
||
std::vector<std::uint8_t> 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<std::uint8_t, 64> 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<unsigned int>(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<unsigned int>(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<std::size_t>(length - sent, 0x4000);
|
||
const Result result = sslConnectionWrite(&sslConnection, data + sent, static_cast<u32>(amount), &written);
|
||
if (R_FAILED(result) || written == 0) return false;
|
||
sent += written;
|
||
}
|
||
return true;
|
||
};
|
||
if (!sendBytes(reinterpret_cast<const std::uint8_t*>(headers.data()), headers.size()) ||
|
||
(!requestBody.empty() && !sendBytes(requestBody.data(), requestBody.size()))) {
|
||
error = "Could not send the Gallery request";
|
||
closeTls();
|
||
return false;
|
||
}
|
||
std::vector<std::uint8_t> received;
|
||
received.reserve(4096);
|
||
std::array<std::uint8_t, 16384> 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<const char*>(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<std::ptrdiff_t>(bodyStart), received.end());
|
||
if (status < 200 || status >= 300) {
|
||
error.assign(responseBody.begin(), responseBody.begin() + static_cast<std::ptrdiff_t>(std::min<std::size_t>(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<std::uint8_t> 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<App*>(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<std::uint8_t>& 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::uint64_t>(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::size_t>(std::numeric_limits<int>::max())) {
|
||
SDL_RWops* stream = SDL_RWFromConstMem(galleryProfile.avatar.data(), static_cast<int>(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<std::uint8_t> 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<std::uint8_t> 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<std::uint8_t>& 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::uint64_t>(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<std::uint8_t> 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::uint64_t>(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::size_t>(std::numeric_limits<int>::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<int>(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<std::uint32_t*>(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<std::streamoff>(input.tellg());
|
||
if (size <= 0 || static_cast<std::uint64_t>(size) > 64U * 1024U * 1024U) {
|
||
input.close();
|
||
std::remove(tempPath.c_str());
|
||
showToast("Switchnote is too large to send to Gallery");
|
||
return;
|
||
}
|
||
std::vector<std::uint8_t> bytes(static_cast<std::size_t>(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<char*>(bytes.data()), static_cast<std::streamsize>(size));
|
||
const bool readOk = input.gcount() == static_cast<std::streamsize>(bytes.size());
|
||
input.close();
|
||
std::remove(tempPath.c_str());
|
||
if (!readOk) { showToast("Could not read signed Switchnote"); return; }
|
||
std::vector<std::uint8_t> 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<std::uint8_t> 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<std::uint8_t> 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<std::size_t>(galleryPage * 6) >= static_cast<std::size_t>(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<std::uint8_t> 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<std::uint8_t> 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<std::uint8_t> 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<const char*>(bytes.data()), static_cast<std::streamsize>(bytes.size()));
|
||
output.flush();
|
||
const bool written = static_cast<bool>(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<int>(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<int>(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<int>(event.tfinger.x * W); pointerY = static_cast<int>(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<int>(event.tfinger.x * W); pointerY = static_cast<int>(event.tfinger.y * H);
|
||
break;
|
||
case SDL_FINGERUP:
|
||
pointerX = static_cast<int>(event.tfinger.x * W); pointerY = static_cast<int>(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<std::uint32_t>(BZL) : 0u) |
|
||
(zr ? static_cast<std::uint32_t>(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<std::pair<std::uint32_t, const char*>, 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<bool>(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<char> logoBytes(static_cast<std::size_t>(logoSize));
|
||
uploaded.seekg(0, std::ios::beg);
|
||
uploaded.read(logoBytes.data(), static_cast<std::streamsize>(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<std::streamsize>(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<char> frameBytes(static_cast<std::size_t>(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<std::streamsize>(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<std::size_t>(size)); logo.seekg(0);
|
||
logo.read(reinterpret_cast<char*>(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<const char*>(bundle.logo.data()), static_cast<std::streamsize>(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<bool>(src) && static_cast<bool>(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<Uint32*>(static_cast<Uint8*>(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<int>::max()) return nullptr;
|
||
std::vector<Uint8> bytes(static_cast<std::size_t>(length));
|
||
file.seekg(0, std::ios::beg);
|
||
if (!file.read(reinterpret_cast<char*>(bytes.data()), length)) return nullptr;
|
||
file.close();
|
||
SDL_RWops* stream = SDL_RWFromConstMem(bytes.data(), static_cast<int>(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<unsigned char>(display[at - 1]));
|
||
const std::size_t end = at + oldTerm.size();
|
||
const bool right = end == display.size() || !std::isalnum(static_cast<unsigned char>(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<float>(radius - row - 1);
|
||
inset = radius - static_cast<int>(std::sqrt(static_cast<float>(radius * radius) - dy * dy));
|
||
} else if (row >= h - radius) {
|
||
const float dy = static_cast<float>(row - (h - radius));
|
||
inset = radius - static_cast<int>(std::sqrt(static_cast<float>(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<float>(r - row - 1);
|
||
return r - static_cast<int>(std::sqrt(static_cast<float>(r * r) - dy * dy));
|
||
}
|
||
if (row >= height - r) {
|
||
const float dy = static_cast<float>(row - (height - r));
|
||
return r - static_cast<int>(std::sqrt(static_cast<float>(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<std::uint64_t>(static_cast<std::uint32_t>(x)) << 32) |
|
||
static_cast<std::uint32_t>(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<int>(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<Uint8>(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<Uint8>(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<float>(sourceW), iconSize / static_cast<float>(sourceH)) : 1.0f;
|
||
const int iconW = std::max(1, static_cast<int>(sourceW * scale));
|
||
const int iconH = std::max(1, static_cast<int>(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<Uint8>(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<Uint8>(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<float>(sourceW), iconSize / static_cast<float>(sourceH)) : 1.0f;
|
||
const int iconW = std::max(1, static_cast<int>(sourceW * scale));
|
||
const int iconH = std::max(1, static_cast<int>(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<Uint8>(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<float>(sourceW), (h - 12) / static_cast<float>(sourceH)) : 1.0f;
|
||
const int iconW = std::max(1, static_cast<int>(sourceW * scale));
|
||
const int iconH = std::max(1, static_cast<int>(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<std::string> 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<int>(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<int>(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<float>(SDL_GetTicks() - start) / 1000.0f;
|
||
update();
|
||
render();
|
||
if (updateInstallPending && static_cast<std::int32_t>(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("<empty>") : 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<unsigned int>(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<App*>(context);
|
||
app->runUpdateTask();
|
||
app->updateWorkerDone.store(true, std::memory_order_release);
|
||
return 0;
|
||
}
|
||
|
||
void App::runUpdateTask() {
|
||
std::vector<std::uint8_t> 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<unsigned long> 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<const char*>(response.data()), static_cast<std::streamsize>(response.size()));
|
||
output.flush();
|
||
const bool writeOk = static_cast<bool>(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<std::uint32_t>(1, static_cast<std::uint32_t>(std::lround(1000.0 / sppm::frameRate(doc.metadata))));
|
||
while (static_cast<std::int32_t>(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<int>(sppm::kWidth * scale);
|
||
const int canvasH = static_cast<int>(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<int>(sppm::kWidth * scale);
|
||
const int canvasH = static_cast<int>(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<int>((sx - canvasX) / (canvasW / static_cast<float>(sppm::kWidth)));
|
||
py = static_cast<int>((sy - canvasY) / (canvasH / static_cast<float>(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<int>(sppm::kWidth * 2.0f * zoom) - W) / 2);
|
||
const int maxPanY = std::max(0, (static_cast<int>(sppm::kHeight * 2.0f * zoom) - H) / 2);
|
||
constexpr float panPixelsPerSecond = 720.0f;
|
||
panX = std::clamp(panX + static_cast<int>(std::lround(leftStickX * panPixelsPerSecond * elapsed)), -maxPanX, maxPanX);
|
||
panY = std::clamp(panY + static_cast<int>(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<int>(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<std::size_t>(speed - 1)];
|
||
if (docLoaded) {
|
||
doc.metadata.fps = static_cast<std::uint8_t>(defaultFps);
|
||
doc.metadata.speed = static_cast<std::uint8_t>(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<int>(doc.audioTracks.size()) &&
|
||
!doc.audioTracks[static_cast<std::size_t>(audioTrack)].pcm.empty()) {
|
||
if (Mix_Playing(4)) Mix_HaltChannel(4);
|
||
else if (audioReady && trackChunks[static_cast<std::size_t>(audioTrack)])
|
||
Mix_PlayChannel(4, trackChunks[static_cast<std::size_t>(audioTrack)], 0);
|
||
} else showToast("No audio in this track yet");
|
||
}
|
||
if ((buttonsDown & (BPLUS | BMINUS)) && audioTrack > 0) {
|
||
while (doc.audioTracks.size() <= static_cast<std::size_t>(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<std::size_t>(audioTrack)].frameTriggers;
|
||
const auto cue = static_cast<std::uint16_t>(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<std::string> 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<std::size_t>(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<std::uint32_t>(1, static_cast<std::uint32_t>(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<std::uint32_t>(1, static_cast<std::uint32_t>(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<std::size_t>(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<std::uint32_t>(1, static_cast<std::uint32_t>(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<int>(sppm::kWidth * 2.0f * zoom) - W) / 2);
|
||
const int maxPanY = std::max(0, (static_cast<int>(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<int>(sourceW * scale), logoH = static_cast<int>(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<Uint8>(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<Uint8>(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<int>(std::lround(sourceW * iconScale)));
|
||
const int iconH = std::max(1, static_cast<int>(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<int>(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<int>(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<std::size_t>(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<std::size_t>(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<std::size_t>(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<int>((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<double>(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<int>(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<std::size_t>(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<std::size_t>(index)]), x + 3, y + 4 - lift, 315, 182);
|
||
roundedOutline(x - 1, y - lift, 323, 190, 12, 4, border);
|
||
if (gallery[static_cast<std::size_t>(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<std::size_t>(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<std::size_t>(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<int>(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<std::size_t>(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<std::size_t>(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<std::size_t>(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<std::size_t>(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<std::size_t>(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<std::uint32_t, std::uint8_t> exact;
|
||
std::map<std::uint32_t, std::uint32_t> frequency;
|
||
for (int y = 0; y < rgba->h; ++y) {
|
||
const auto* row = reinterpret_cast<const std::uint32_t*>(static_cast<const std::uint8_t*>(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<std::uint32_t>(a) << 24) | (static_cast<std::uint32_t>(r) << 16) | (static_cast<std::uint32_t>(g) << 8) | b];
|
||
}
|
||
}
|
||
std::vector<std::pair<std::uint32_t, std::uint32_t>> 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<std::uint8_t>(key >> 16), static_cast<std::uint8_t>(key >> 8), static_cast<std::uint8_t>(key), static_cast<std::uint8_t>(key >> 24)};
|
||
exact[key] = static_cast<std::uint8_t>(i + 1);
|
||
}
|
||
auto& pixels = out.frames[0].layers[3];
|
||
for (int y = 0; y < rgba->h; ++y) {
|
||
const auto* row = reinterpret_cast<const std::uint32_t*>(static_cast<const std::uint8_t*>(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<std::uint32_t>(a) << 24) | (static_cast<std::uint32_t>(r) << 16) | (static_cast<std::uint32_t>(g) << 8) | b;
|
||
auto found = exact.find(key);
|
||
if (found != exact.end()) pixels[static_cast<std::size_t>(y) * rgba->w + x] = found->second;
|
||
else {
|
||
int bestDistance = std::numeric_limits<int>::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<int>(r) - c.r, dg = static_cast<int>(g) - c.g, db = static_cast<int>(b) - c.b;
|
||
const int d = dr * dr + dg * dg + db * db;
|
||
if (d < bestDistance) { bestDistance = d; best = static_cast<std::uint8_t>(i); }
|
||
}
|
||
pixels[static_cast<std::size_t>(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<std::uint8_t>(defaultFps);
|
||
doc.metadata.speed = static_cast<std::uint8_t>(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<int>(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<int>(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<int>(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<std::uint32_t*>(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<std::uint8_t>((c.r * alpha + r * oldA * inv / 255 + nextA / 2) / nextA);
|
||
g = static_cast<std::uint8_t>((c.g * alpha + g * oldA * inv / 255 + nextA / 2) / nextA);
|
||
b = static_cast<std::uint8_t>((c.b * alpha + b * oldA * inv / 255 + nextA / 2) / nextA);
|
||
out = SDL_MapRGBA(surface->format, r, g, b, static_cast<std::uint8_t>(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<std::uint32_t>(1, static_cast<std::uint32_t>(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::size_t>(
|
||
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<Uint8*>(buffer.data() + offset),
|
||
static_cast<Uint32>(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<int>(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<int>(track.sampleRate), AUDIO_S16SYS, 2, 48000) < 0) continue;
|
||
const auto bytes = track.pcm.size() * sizeof(std::int16_t);
|
||
if (bytes > static_cast<std::size_t>(std::numeric_limits<int>::max())) continue;
|
||
cvt.len = static_cast<int>(bytes);
|
||
cvt.buf = static_cast<Uint8*>(SDL_malloc(static_cast<std::size_t>(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<std::size_t>(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<std::size_t>(
|
||
1, static_cast<std::size_t>(std::ceil(inputFrames / playbackRate)));
|
||
const std::size_t outputFrames = std::min(requestedFrames, maxOutputFrames);
|
||
trackAudioBuffers[i].resize(outputFrames * 4);
|
||
const auto* sourceSamples = reinterpret_cast<const std::int16_t*>(cvt.buf);
|
||
auto* outputSamples = reinterpret_cast<std::int16_t*>(trackAudioBuffers[i].data());
|
||
for (std::size_t outFrame = 0; outFrame < outputFrames; ++outFrame) {
|
||
const double sourcePosition = std::min(static_cast<double>(inputFrames - 1), outFrame * playbackRate);
|
||
const std::size_t sourceFrame = static_cast<std::size_t>(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::int16_t>(std::clamp(
|
||
static_cast<int>(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<Uint32>(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<std::uint16_t>(frame)) != cuesAt.end() && trackChunks[i])
|
||
Mix_PlayChannel(static_cast<int>(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<std::size_t>(layer)];
|
||
const std::size_t seed = static_cast<std::size_t>(y) * sppm::kWidth + x;
|
||
const std::uint8_t target = pixels[seed];
|
||
const std::uint8_t replacement = activeTool == Tool::Eraser
|
||
? 0 : static_cast<std::uint8_t>(std::clamp(color, 1, 255));
|
||
if (target == replacement) return;
|
||
|
||
pushUndo();
|
||
fillWavePixels.clear();
|
||
fillWavePixels.reserve(pixels.size() / 4);
|
||
std::vector<std::uint32_t> queue;
|
||
queue.reserve(pixels.size() / 4);
|
||
std::vector<std::uint8_t> visited(pixels.size(), 0);
|
||
std::vector<std::uint16_t> distances(pixels.size(), std::numeric_limits<std::uint16_t>::max());
|
||
queue.push_back(static_cast<std::uint32_t>(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<int>(index % sppm::kWidth);
|
||
const int py = static_cast<int>(index / sppm::kWidth);
|
||
fillWavePixels.emplace_back(index, distances[index]);
|
||
const std::array<int, 4> neighbors{{px > 0 ? static_cast<int>(index) - 1 : -1,
|
||
px + 1 < sppm::kWidth ? static_cast<int>(index) + 1 : -1,
|
||
py > 0 ? static_cast<int>(index) - sppm::kWidth : -1,
|
||
py + 1 < sppm::kHeight ? static_cast<int>(index) + sppm::kWidth : -1}};
|
||
for (const int next : neighbors) {
|
||
if (next < 0 || visited[static_cast<std::size_t>(next)] || pixels[static_cast<std::size_t>(next)] != target) continue;
|
||
visited[static_cast<std::size_t>(next)] = 1;
|
||
distances[static_cast<std::size_t>(next)] = static_cast<std::uint16_t>(distances[index] + 1);
|
||
queue.push_back(static_cast<std::uint32_t>(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<std::size_t>(layer)];
|
||
bool changed = false;
|
||
const auto radius = static_cast<std::uint16_t>(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<std::ptrdiff_t>(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<std::ptrdiff_t>(frame));
|
||
for (auto& track : doc.audioTracks) {
|
||
track.frameTriggers.erase(std::remove(track.frameTriggers.begin(), track.frameTriggers.end(), static_cast<std::uint16_t>(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<std::uint32_t*>(surface->pixels);
|
||
const auto& frameData = source.frames[std::min<std::size_t>(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<std::string, 4> tools{{"New", "Erase", "Copy", "Paste"}};
|
||
const std::array<std::string, 4> 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<int>(doc.frames.size()));
|
||
const int gap = 14;
|
||
constexpr int cardW = 174;
|
||
constexpr int cardH = 112;
|
||
const int firstFrame = std::clamp(static_cast<int>(frame) - 2, 0,
|
||
std::max(0, static_cast<int>(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<std::size_t>(idx) == frame) rounded(x - 5, y - 5, cardW + 10, cardH + 10, 14, ORANGE);
|
||
rounded(x, y, cardW, cardH, 10, WHITE);
|
||
drawFrameImage(static_cast<std::size_t>(idx), x + 4, y + 4, cardW - 8, cardH - 8);
|
||
if (hit(x, y, cardW, cardH)) { setFrame(static_cast<std::size_t>(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<int>(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<std::size_t>(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<int>(sppm::kWidth * scale);
|
||
const int ch = static_cast<int>(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<int>(x * cw / static_cast<float>(sppm::kWidth));
|
||
SDL_RenderDrawLine(renderer, px, cy, px, cy + ch);
|
||
}
|
||
for (int y = 0; y <= sppm::kHeight; ++y) {
|
||
const int py = cy + static_cast<int>(y * ch / static_cast<float>(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<int>(std::floor(fillWaveRadius));
|
||
const int pixelW = std::max(1, static_cast<int>(std::ceil(cw / static_cast<float>(sppm::kWidth))));
|
||
const int pixelH = std::max(1, static_cast<int>(std::ceil(ch / static_cast<float>(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<int>(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<int>(entry.first % sppm::kWidth);
|
||
const int py = static_cast<int>(entry.first / sppm::kWidth);
|
||
SDL_Rect dot{cx + static_cast<int>(px * cw / static_cast<float>(sppm::kWidth)),
|
||
cy + static_cast<int>(py * ch / static_cast<float>(sppm::kHeight)), pixelW, pixelH};
|
||
SDL_RenderFillRect(renderer, &dot);
|
||
}
|
||
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
|
||
SDL_RenderSetClipRect(renderer, nullptr);
|
||
}
|
||
const int chromeTravel = static_cast<int>(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<char>('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::size_t>(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<std::size_t>(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<int>(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<int, 6> brushSizes{{1, 2, 4, 8, 16, 32}};
|
||
int selectedBrush = 0;
|
||
for (int i = 1; i < static_cast<int>(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<const char*, 16> 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<int>(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<std::size_t>(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<const char*, 8> 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<std::size_t>(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<int>(kInkPresetIndexes.size()); ++i) {
|
||
const int index = kInkPresetIndexes[static_cast<std::size_t>(i)];
|
||
const int x = 875 + (i % 5) * 57, y = 126 + (i / 5) * 54;
|
||
const auto c = doc.palette[static_cast<std::size_t>(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<Tool, 3> tools{{Tool::Pen, Tool::Brush, Tool::Eraser}};
|
||
const std::array<const char*, 3> 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<float>(sppm::kWidth), H / static_cast<float>(sppm::kHeight));
|
||
const int cw = static_cast<int>(sppm::kWidth * scale);
|
||
const int ch = static_cast<int>(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<std::vector<std::string>> wrappedComments(galleryComments.size());
|
||
std::vector<int> 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<unsigned char>(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<int>(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<int>(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<int>(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<std::uint32_t*>(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<char>(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<Uint32>(std::numeric_limits<int>::max())) {
|
||
SDL_FreeWAV(raw);
|
||
showToast("Use a valid mono or stereo WAV file");
|
||
return false;
|
||
}
|
||
const std::size_t bytesPerFrame = static_cast<std::size_t>(source.channels) * bytesPerSample;
|
||
const std::size_t availableFrames = rawLength / bytesPerFrame;
|
||
const int maximumSeconds = targetTrack == 0 ? 60 : 2;
|
||
const std::size_t maximumFrames = static_cast<std::size_t>(maximumSeconds) * static_cast<std::size_t>(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<int>(framesToKeep * bytesPerFrame);
|
||
cvt.buf = static_cast<Uint8*>(SDL_malloc(static_cast<std::size_t>(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<std::size_t>(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<std::size_t>(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<std::size_t>(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<std::size_t>(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<std::size_t>(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<std::uint16_t>(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<unsigned char>(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<std::uint8_t> image(static_cast<std::size_t>(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<std::uint8_t>(red);
|
||
image[offset + 1] = static_cast<std::uint8_t>(green);
|
||
image[offset + 2] = static_cast<std::uint8_t>(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<App*>(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<HidNpadIdType, 9> cameraCandidates = handheld
|
||
? std::array<HidNpadIdType, 9>{HidNpadIdType_Handheld, HidNpadIdType_No1, HidNpadIdType_No2,
|
||
HidNpadIdType_No3, HidNpadIdType_No4, HidNpadIdType_No5, HidNpadIdType_No6, HidNpadIdType_No7, HidNpadIdType_No8}
|
||
: std::array<HidNpadIdType, 9>{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<std::size_t>::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<u64>(count, 2048);
|
||
std::vector<CapsAlbumEntry> entries(static_cast<std::size_t>(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::size_t>(std::min<u64>(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<std::uint8_t> encoded(static_cast<std::size_t>(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<int>(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<float>(sppm::kHeight) / rgba->h);
|
||
const int drawW = std::max(1, static_cast<int>(std::lround(rgba->w * scale)));
|
||
const int drawH = std::max(1, static_cast<int>(std::lround(rgba->h * scale)));
|
||
const int left = (600 - drawW) / 2, top = (static_cast<int>(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<const std::uint8_t*>(rgba->pixels) + static_cast<std::size_t>(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<std::size_t>(sx) * 4U;
|
||
auto* dst = photoImportRgba.data() + (static_cast<std::size_t>(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<std::size_t>(photoImportLayer)].begin(),
|
||
doc.frames[frame].layers[static_cast<std::size_t>(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<std::size_t>(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<std::uint8_t, 256> grayToPalette{};
|
||
for (int shade = 0; shade < 256; ++shade) {
|
||
int bestError = std::numeric_limits<int>::max();
|
||
std::uint8_t bestIndex = 1;
|
||
for (int index = 16; index < 256; ++index) {
|
||
const auto& c = palette[static_cast<std::size_t>(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<std::uint8_t>(index); }
|
||
}
|
||
grayToPalette[static_cast<std::size_t>(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<std::size_t>(sourceY) * 320 + sourceX];
|
||
pixels[static_cast<std::size_t>(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<std::streamoff>(photoBytes)) return false;
|
||
photoImportRgba.resize(photoBytes);
|
||
image.seekg(0, std::ios::beg);
|
||
image.read(reinterpret_cast<char*>(photoImportRgba.data()), static_cast<std::streamsize>(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<std::size_t>(targetLayer)].begin(),
|
||
doc.frames[frame].layers[static_cast<std::size_t>(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<std::size_t>(photoImportLayer)];
|
||
const int endRow = std::min<int>(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<std::size_t>(y) * 600U + static_cast<std::size_t>(sourceX)) * 4U;
|
||
if (photoImportRgba[source + 3] < 24) continue;
|
||
int bestError = std::numeric_limits<int>::max();
|
||
std::uint8_t bestIndex = 1;
|
||
for (int index : kInkPresetIndexes) {
|
||
const auto& candidate = doc.palette[static_cast<std::size_t>(index)];
|
||
const int dr = static_cast<int>(candidate.r) - photoImportRgba[source];
|
||
const int dg = static_cast<int>(candidate.g) - photoImportRgba[source + 1];
|
||
const int db = static_cast<int>(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<std::uint8_t>(index); }
|
||
}
|
||
pixels[static_cast<std::size_t>(y) * sppm::kWidth + static_cast<std::size_t>(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<char>('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<int>(eased * (targetY + h))
|
||
: -h + static_cast<int>(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<std::string> 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<int>(i) * 50;
|
||
if (pill(folders[i], x + 52, by, w - 104, 42,
|
||
folders[i] == folder || static_cast<int>(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<int>(doc.audioTracks.size()) &&
|
||
!doc.audioTracks[static_cast<std::size_t>(audioTrack)].pcm.empty();
|
||
const sppm::AudioTrack* selectedTrack = trackPresent ? &doc.audioTracks[static_cast<std::size_t>(audioTrack)] : nullptr;
|
||
const double seconds = selectedTrack && selectedTrack->channels && selectedTrack->sampleRate
|
||
? selectedTrack->pcm.size() / static_cast<double>(selectedTrack->channels * selectedTrack->sampleRate) : 0.0;
|
||
const std::string durationText = std::to_string(static_cast<int>(std::lround(seconds * 10)) / 10) + "." +
|
||
std::to_string(static_cast<int>(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<int>(doc.audioTracks.size())) {
|
||
const auto& cues = doc.audioTracks[static_cast<std::size_t>(audioTrack)].frameTriggers;
|
||
cueHere = std::find(cues.begin(), cues.end(), static_cast<std::uint16_t>(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<std::size_t>(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<std::size_t>(audioTrack)].frameTriggers;
|
||
const auto cue = static_cast<std::uint16_t>(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<std::size_t>(audioTrack)], "")) {
|
||
if (Mix_Playing(4)) Mix_HaltChannel(4);
|
||
else if (trackChunks[static_cast<std::size_t>(audioTrack)])
|
||
Mix_PlayChannel(4, trackChunks[static_cast<std::size_t>(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<std::size_t>(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<std::size_t>(speed - 1)];
|
||
if (docLoaded) {
|
||
doc.metadata.fps = static_cast<std::uint8_t>(defaultFps);
|
||
doc.metadata.speed = static_cast<std::uint8_t>(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<std::size_t>(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<std::uint8_t> 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<int>(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<int>(slot % 3), row = static_cast<int>(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<int>((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<std::size_t>::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<std::size_t>::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<std::uint8_t>& pixels) {
|
||
const float scale = screen == Screen::CommentCanvas ? 2.0f : 2.0f * zoom;
|
||
const int width = static_cast<int>(sppm::kWidth * scale);
|
||
const int left = screen == Screen::CommentCanvas ? 0 : (W - width) / 2 + panX;
|
||
const int height = static_cast<int>(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<float>(sppm::kWidth);
|
||
const float pixelHeight = height / static_cast<float>(sppm::kHeight);
|
||
const int px = static_cast<int>(std::floor((sx - left) / pixelWidth));
|
||
const int py = static_cast<int>(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<int>(std::floor((lastStrokeX - left) / pixelWidth));
|
||
previousPy = static_cast<int>(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::uint8_t>(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<std::size_t>(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<std::size_t>(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<float>(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<int, 8> radii{{0, 1, 3, 6, 4, 7, 6, 6}};
|
||
const int radius = radii[static_cast<std::size_t>(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<float>(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<std::uint16_t>(penRngState | 0x8000u);
|
||
return penRngState;
|
||
};
|
||
auto randomRange = [&](int range) { return static_cast<int>(random16() % static_cast<std::uint16_t>(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<const char*, 5> smallMask{{"..#..", ".###.", "#####", ".###.", "..#.."}};
|
||
static const std::array<const char*, 15> 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<std::size_t>(row)][col] : smallMask[static_cast<std::size_t>(row)][col];
|
||
if (mark != '#' || static_cast<int>(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<const char*, 7> outer{{"..###..", ".#####.", "#######", "#######", "#######", ".#####.", "..###.."}};
|
||
static const std::array<const char*, 5> inner{{"..#..", ".###.", "#####", ".###.", "..#.."}};
|
||
for (int y = 0; y < 7; ++y) for (int x = 0; x < 7; ++x)
|
||
if (outer[static_cast<std::size_t>(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<std::size_t>(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<float>(sx);
|
||
realtimeStrokeY = static_cast<float>(sy);
|
||
realtimeStrokeInitialized = true;
|
||
} else {
|
||
realtimeStrokeX += (sx - realtimeStrokeX) * follow;
|
||
realtimeStrokeY += (sy - realtimeStrokeY) * follow;
|
||
}
|
||
sx = static_cast<int>(std::lround(realtimeStrokeX));
|
||
sy = static_cast<int>(std::lround(realtimeStrokeY));
|
||
}
|
||
std::vector<std::uint8_t>* 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<std::size_t>(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<int>(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<std::size_t>(radius) ? i - static_cast<std::size_t>(radius) : 0;
|
||
const std::size_t to = std::min(points.size() - 1, i + static_cast<std::size_t>(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
|
