diff --git a/PiPedalCommon/src/HtmlHelper.cpp b/PiPedalCommon/src/HtmlHelper.cpp index 607c7aa..1b887dc 100644 --- a/PiPedalCommon/src/HtmlHelper.cpp +++ b/PiPedalCommon/src/HtmlHelper.cpp @@ -24,22 +24,20 @@ using namespace pipedal; - // non-localizable. RFC 7231 Date Format ; -static const char*INVARIANT_WEEK_DAYS[] = - { "Sun","Mon", "Tue", "Wed","Thu", "Fri","Sat"}; +static const char *INVARIANT_WEEK_DAYS[] = + {"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"}; // non-localizable. RFC 7231 Date Format -static const char*INVARIANT_MONTHS[] = { "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep","Oct","Nov", "Dec"}; +static const char *INVARIANT_MONTHS[] = {"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"}; // non-localizable. RFC 7231 Date Format -static const char*FORMAT = "%s, %02d %s %04d %02d:%02d:%02d GMT"; +static const char *FORMAT = "%s, %02d %s %04d %02d:%02d:%02d GMT"; std::string HtmlHelper::timeToHttpDate() { return timeToHttpDate(time(nullptr)); } - std::string HtmlHelper::timeToHttpDate(std::filesystem::file_time_type time) { @@ -56,29 +54,30 @@ std::string HtmlHelper::timeToHttpDate(time_t time) struct tm gmTm; - if (gmtime_r(&time,&gmTm) == nullptr) + if (gmtime_r(&time, &gmTm) == nullptr) { return "Mon, 01 Jan 1970 00:00:00 GMT"; } char szBuffer[sizeof("Mon, 01 Jan 1970 00:00:00 GMT+")]; - snprintf(szBuffer,sizeof(szBuffer), FORMAT, - INVARIANT_WEEK_DAYS[gmTm.tm_wday], - gmTm.tm_mday, - INVARIANT_MONTHS[gmTm.tm_mon], - gmTm.tm_year+1900, - gmTm.tm_hour, - gmTm.tm_min, - gmTm.tm_sec); + snprintf(szBuffer, sizeof(szBuffer), FORMAT, + INVARIANT_WEEK_DAYS[gmTm.tm_wday], + gmTm.tm_mday, + INVARIANT_MONTHS[gmTm.tm_mon], + gmTm.tm_year + 1900, + gmTm.tm_hour, + gmTm.tm_min, + gmTm.tm_sec); return szBuffer; - } - static int hexN(char c) { - if (c >= '0' && c <= '9') return c-'0'; - if (c >= 'a' && c <= 'f') return 10+c-'a'; - if (c >= 'A' && c <= 'F') return 10+c-'A'; + if (c >= '0' && c <= '9') + return c - '0'; + if (c >= 'a' && c <= 'f') + return 10 + c - 'a'; + if (c >= 'A' && c <= 'F') + return 10 + c - 'A'; throw std::invalid_argument("Malformed URL."); } static char hexChar(char c1, char c2) @@ -86,15 +85,19 @@ static char hexChar(char c1, char c2) return (char)((hexN(c1) << 4) + hexN(c2)); } -class SafeCharacterMap { +class SafeCharacterMap +{ bool safeCharacter[256]; + public: - SafeCharacterMap() + SafeCharacterMap() { - for (int i = 0; i < 0x20; ++i) { + for (int i = 0; i < 0x20; ++i) + { safeCharacter[i] = false; } - for (int i = 0x20; i < 0x80; ++i) { + for (int i = 0x20; i < 0x80; ++i) + { safeCharacter[i] = true; } for (int i = 0x80; i < 256; ++i) @@ -102,27 +105,25 @@ public: safeCharacter[i] = false; } const char *unsafeCharacters = ":/+?=#%@ "; - for (const char*p = unsafeCharacters; *p != 0; ++p) + for (const char *p = unsafeCharacters; *p != 0; ++p) { safeCharacter[(uint8_t)*p] = false; } } - - bool isSafeCharacter(char c) { return safeCharacter[(uint8_t)c];} + bool isSafeCharacter(char c) { return safeCharacter[(uint8_t)c]; } } g_safeCharacterMap; - -std::string HtmlHelper::encode_url_segment(const char*pStart, const char*pEnd, bool isQuerySegment ) +std::string HtmlHelper::encode_url_segment(const char *pStart, const char *pEnd, bool isQuerySegment) { std::stringstream s; - encode_url_segment(s,pStart,pEnd,isQuerySegment); + encode_url_segment(s, pStart, pEnd, isQuerySegment); return s.str(); } static char hexChars[] = "0123456789ABCDEF"; -void HtmlHelper::encode_url_segment(std::ostream&os, const char*pStart, const char *pEnd, bool isQuerySegment) +void HtmlHelper::encode_url_segment(std::ostream &os, const char *pStart, const char *pEnd, bool isQuerySegment) { while (pStart != pEnd) { @@ -130,21 +131,21 @@ void HtmlHelper::encode_url_segment(std::ostream&os, const char*pStart, const ch if (g_safeCharacterMap.isSafeCharacter(c)) { os << c; - } else if (c == ' ' && isQuerySegment) + } + else if (c == ' ' && isQuerySegment) { os << '+'; os << c; - } else { + } + else + { os << '%' << hexChars[(c >> 4) & 0x0f] << hexChars[c & 0x0F]; } } } - - - -std::string HtmlHelper::decode_url_segment(const char*pStart, const char*pEnd, bool isQuery) +std::string HtmlHelper::decode_url_segment(const char *pStart, const char *pEnd, bool isQuery) { std::stringstream s; auto p = pStart; @@ -155,16 +156,23 @@ std::string HtmlHelper::decode_url_segment(const char*pStart, const char*pEnd, b if (c == '+') { s << ' '; - } else if (c != '%') + } + else if (c != '%') { s << c; - } else { - if (pStart+2 < pEnd) + } + else + { + if (pStart + 2 < pEnd) + { + char c1 = *p; + ++p; + char c2 = *p; + ++p; + s << hexChar(c1, c2); + } + else { - char c1 = *p; ++p; - char c2 = *p; ++p; - s << hexChar(c1,c2); - } else { throw std::invalid_argument("Malformed URL."); } } @@ -172,9 +180,9 @@ std::string HtmlHelper::decode_url_segment(const char*pStart, const char*pEnd, b return s.str(); } -std::string HtmlHelper::decode_url_segment(const char*text, bool isQuery) +std::string HtmlHelper::decode_url_segment(const char *text, bool isQuery) { - return decode_url_segment(text, text + strlen(text),isQuery); + return decode_url_segment(text, text + strlen(text), isQuery); } void HtmlHelper::utf32_to_utf8_stream(std::ostream &s, uint32_t uc) @@ -182,20 +190,27 @@ void HtmlHelper::utf32_to_utf8_stream(std::ostream &s, uint32_t uc) if (uc < 0x80u) { s << (char)uc; - } else if (uc < 0x800u) { + } + else if (uc < 0x800u) + { s << (char)(0xC0 + (uc >> 6)); s << (char)(0x80 + (uc & 0x3F)); - - } else if (uc < 0x10000u) { + } + else if (uc < 0x10000u) + { s << (char)(0xE0 + (uc >> 12)); s << (char)(0x80 + ((uc >> 6) & 0x3F)); s << (char)(0x80 + (uc & 0x3F)); - } else if (uc < 0x0110000) { + } + else if (uc < 0x0110000) + { s << (char)(0xF0 + (uc >> 18)); s << (char)(0x80 + ((uc >> 12) & 0x3F)); s << (char)(0x80 + ((uc >> 6) & 0x3F)); s << (char)(0x80 + (uc & 0x3F)); - } else { + } + else + { throw std::range_error("Illegal UTF-32 character."); } } @@ -205,20 +220,22 @@ const std::string ESPECIALS = "()<>@,;:\"/[]?.="; #define MAX_FILENAME_LENGTH 96 #define MAX_SEGMENT_LENGTH 74 // 75 per RFC2047.2. But leave space for a ';' to allow easier breaking of lines. -std::string HtmlHelper::Rfc5987EncodeFileName(const std::string&name) +std::string HtmlHelper::Rfc5987EncodeFileName(const std::string &name) { // Encode all characters. Let the browser handle safe-ifying the actual name, since that's os dependent. std::stringstream s; std::string prefix = "UTF-8''"; std::string postfix = ""; s << prefix; - for (char c: name) + for (char c : name) { uint8_t uc = (uint8_t)c; if (uc < 0x20 || uc >= 0x80 || ESPECIALS.find(c) != std::string::npos) { s << '%' << hexChars[uc >> 4] << hexChars[uc & 0x0F]; - } else { + } + else + { s << c; } } @@ -226,7 +243,6 @@ std::string HtmlHelper::Rfc5987EncodeFileName(const std::string&name) return s.str(); } - #define MAX_FILE_NAME_LENGHT 96 const std::string SF_SPECIALS = " <>@;:\"\'/[]?="; @@ -249,23 +265,126 @@ std::string HtmlHelper::SafeFileName(const std::string &name) return s.str(); } -std::string HtmlHelper::HtmlEncode(const std::string& text) +std::string HtmlHelper::HtmlEncode(const std::string &text) { std::stringstream os; - for (char c: text) + for (char c : text) { - switch(c) + switch (c) { - case '<': os << "<"; break; - case '>': os << ">"; break; - case '&': os << "&"; break; - default: os << c; break; - + case '<': + os << "<"; + break; + case '>': + os << ">"; + break; + case '&': + os << "&"; + break; + default: + os << c; + break; } } return os.str(); } +static const uint64_t crc64_table[256] = { + 0x0000000000000000ULL, 0x42F0E1EBA9EA3693ULL, 0x85E1C3D753D46D26ULL, 0xC711223CFA3E5BB5ULL, + 0x493366450E42ECDFULL, 0x0BC387AEA7A8DA4CULL, 0xCCD2A5925D9681F9ULL, 0x8E224479F47CB76AULL, + 0x9266CC8A1C85D9BEULL, 0xD0962D61B56FEF2DULL, 0x17870F5D4F51B498ULL, 0x5577EEB6E6BB820BULL, + 0xDB55AACF12C73561ULL, 0x99A54B24BB2D03F2ULL, 0x5EB4691841135847ULL, 0x1C4488F3E8F96ED4ULL, + 0x663D78FF90E185EFULL, 0x24CD9914390BB37CULL, 0xE3DCBB28C335E8C9ULL, 0xA12C5AC36ADFDE5AULL, + 0x2F0E1EBA9EA36930ULL, 0x6DFEFF5137495FA3ULL, 0xAAEFDD6DCD770416ULL, 0xE81F3C86649D3285ULL, + 0xF45BB4758C645C51ULL, 0xB6AB559E258E6AC2ULL, 0x71BA77A2DFB03177ULL, 0x334A9649765A07E4ULL, + 0xBD68D2308226B08EULL, 0xFF9833DB2BCC861DULL, 0x388911E7D1F2DDA8ULL, 0x7A79F00C7818EB3BULL, + 0xCC7AF1FF21C30BDEULL, 0x8E8A101488293D4DULL, 0x499B3228721766F8ULL, 0x0B6BD3C3DBFD506BULL, + 0x854997BA2F81E701ULL, 0xC7B97651866BD192ULL, 0x00A8546D7C558A27ULL, 0x4258B586D5BFBCB4ULL, + 0x5E1C3D753D46D260ULL, 0x1CECDC9E94ACE4F3ULL, 0xDBFDFEA26E92BF46ULL, 0x990D1F49C77889D5ULL, + 0x172F5B3033043EBFULL, 0x55DFBADB9AEE082CULL, 0x92CE98E760D05399ULL, 0xD03E790CC93A650AULL, + 0xAA478900B1228E31ULL, 0xE8B768EB18C8B8A2ULL, 0x2FA64AD7E2F6E317ULL, 0x6D56AB3C4B1CD584ULL, + 0xE374EF45BF6062EEULL, 0xA1840EAE168A547DULL, 0x66952C92ECB40FC8ULL, 0x2465CD79455E395BULL, + 0x3821458AADA7578FULL, 0x7AD1A461044D611CULL, 0xBDC0865DFE733AA9ULL, 0xFF3067B657990C3AULL, + 0x711223CFA3E5BB50ULL, 0x33E2C2240A0F8DC3ULL, 0xF4F3E018F031D676ULL, 0xB60301F359DBE0E5ULL, + 0xDA050215EA6C212FULL, 0x98F5E3FE438617BCULL, 0x5FE4C1C2B9B84C09ULL, 0x1D14202910527A9AULL, + 0x93366450E42ECDF0ULL, 0xD1C685BB4DC4FB63ULL, 0x16D7A787B7FAA0D6ULL, 0x5427466C1E109645ULL, + 0x4863CE9FF6E9F891ULL, 0x0A932F745F03CE02ULL, 0xCD820D48A53D95B7ULL, 0x8F72ECA30CD7A324ULL, + 0x0150A8DAF8AB144EULL, 0x43A04931514122DDULL, 0x84B16B0DAB7F7968ULL, 0xC6418AE602954FFBULL, + 0xBC387AEA7A8DA4C0ULL, 0xFEC89B01D3679253ULL, 0x39D9B93D2959C9E6ULL, 0x7B2958D680B3FF75ULL, + 0xF50B1CAF74CF481FULL, 0xB7FBFD44DD257E8CULL, 0x70EADF78271B2539ULL, 0x321A3E938EF113AAULL, + 0x2E5EB66066087D7EULL, 0x6CAE578BCFE24BEDULL, 0xABBF75B735DC1058ULL, 0xE94F945C9C3626CBULL, + 0x676DD025684A91A1ULL, 0x259D31CEC1A0A732ULL, 0xE28C13F23B9EFC87ULL, 0xA07CF2199274CA14ULL, + 0x167FF3EACBAF2AF1ULL, 0x548F120162451C62ULL, 0x939E303D987B47D7ULL, 0xD16ED1D631917144ULL, + 0x5F4C95AFC5EDC62EULL, 0x1DBC74446C07F0BDULL, 0xDAAD56789639AB08ULL, 0x985DB7933FD39D9BULL, + 0x84193F60D72AF34FULL, 0xC6E9DE8B7EC0C5DCULL, 0x01F8FCB784FE9E69ULL, 0x43081D5C2D14A8FAULL, + 0xCD2A5925D9681F90ULL, 0x8FDAB8CE70822903ULL, 0x48CB9AF28ABC72B6ULL, 0x0A3B7B1923564425ULL, + 0x70428B155B4EAF1EULL, 0x32B26AFEF2A4998DULL, 0xF5A348C2089AC238ULL, 0xB753A929A170F4ABULL, + 0x3971ED50550C43C1ULL, 0x7B810CBBFCE67552ULL, 0xBC902E8706D82EE7ULL, 0xFE60CF6CAF321874ULL, + 0xE224479F47CB76A0ULL, 0xA0D4A674EE214033ULL, 0x67C58448141F1B86ULL, 0x253565A3BDF52D15ULL, + 0xAB1721DA49899A7FULL, 0xE9E7C031E063ACECULL, 0x2EF6E20D1A5DF759ULL, 0x6C0603E6B3B7C1CAULL, + 0xF6FAE5C07D3274CDULL, 0xB40A042BD4D8425EULL, 0x731B26172EE619EBULL, 0x31EBC7FC870C2F78ULL, + 0xBFC9838573709812ULL, 0xFD39626EDA9AAE81ULL, 0x3A28405220A4F534ULL, 0x78D8A1B9894EC3A7ULL, + 0x649C294A61B7AD73ULL, 0x266CC8A1C85D9BE0ULL, 0xE17DEA9D3263C055ULL, 0xA38D0B769B89F6C6ULL, + 0x2DAF4F0F6FF541ACULL, 0x6F5FAEE4C61F773FULL, 0xA84E8CD83C212C8AULL, 0xEABE6D3395CB1A19ULL, + 0x90C79D3FEDD3F122ULL, 0xD2377CD44439C7B1ULL, 0x15265EE8BE079C04ULL, 0x57D6BF0317EDAA97ULL, + 0xD9F4FB7AE3911DFDULL, 0x9B041A914A7B2B6EULL, 0x5C1538ADB04570DBULL, 0x1EE5D94619AF4648ULL, + 0x02A151B5F156289CULL, 0x4051B05E58BC1E0FULL, 0x87409262A28245BAULL, 0xC5B073890B687329ULL, + 0x4B9237F0FF14C443ULL, 0x0962D61B56FEF2D0ULL, 0xCE73F427ACC0A965ULL, 0x8C8315CC052A9FF6ULL, + 0x3A80143F5CF17F13ULL, 0x7870F5D4F51B4980ULL, 0xBF61D7E80F251235ULL, 0xFD913603A6CF24A6ULL, + 0x73B3727A52B393CCULL, 0x31439391FB59A55FULL, 0xF652B1AD0167FEEAULL, 0xB4A25046A88DC879ULL, + 0xA8E6D8B54074A6ADULL, 0xEA16395EE99E903EULL, 0x2D071B6213A0CB8BULL, 0x6FF7FA89BA4AFD18ULL, + 0xE1D5BEF04E364A72ULL, 0xA3255F1BE7DC7CE1ULL, 0x64347D271DE22754ULL, 0x26C49CCCB40811C7ULL, + 0x5CBD6CC0CC10FAFCULL, 0x1E4D8D2B65FACC6FULL, 0xD95CAF179FC497DAULL, 0x9BAC4EFC362EA149ULL, + 0x158E0A85C2521623ULL, 0x577EEB6E6BB820B0ULL, 0x906FC95291867B05ULL, 0xD29F28B9386C4D96ULL, + 0xCEDBA04AD0952342ULL, 0x8C2B41A1797F15D1ULL, 0x4B3A639D83414E64ULL, 0x09CA82762AAB78F7ULL, + 0x87E8C60FDED7CF9DULL, 0xC51827E4773DF90EULL, 0x020905D88D03A2BBULL, 0x40F9E43324E99428ULL, + 0x2CFFE7D5975E55E2ULL, 0x6E0F063E3EB46371ULL, 0xA91E2402C48A38C4ULL, 0xEBEEC5E96D600E57ULL, + 0x65CC8190991CB93DULL, 0x273C607B30F68FAEULL, 0xE02D4247CAC8D41BULL, 0xA2DDA3AC6322E288ULL, + 0xBE992B5F8BDB8C5CULL, 0xFC69CAB42231BACFULL, 0x3B78E888D80FE17AULL, 0x7988096371E5D7E9ULL, + 0xF7AA4D1A85996083ULL, 0xB55AACF12C735610ULL, 0x724B8ECDD64D0DA5ULL, 0x30BB6F267FA73B36ULL, + 0x4AC29F2A07BFD00DULL, 0x08327EC1AE55E69EULL, 0xCF235CFD546BBD2BULL, 0x8DD3BD16FD818BB8ULL, + 0x03F1F96F09FD3CD2ULL, 0x41011884A0170A41ULL, 0x86103AB85A2951F4ULL, 0xC4E0DB53F3C36767ULL, + 0xD8A453A01B3A09B3ULL, 0x9A54B24BB2D03F20ULL, 0x5D45907748EE6495ULL, 0x1FB5719CE1045206ULL, + 0x919735E51578E56CULL, 0xD367D40EBC92D3FFULL, 0x1476F63246AC884AULL, 0x568617D9EF46BED9ULL, + 0xE085162AB69D5E3CULL, 0xA275F7C11F7768AFULL, 0x6564D5FDE549331AULL, 0x279434164CA30589ULL, + 0xA9B6706FB8DFB2E3ULL, 0xEB46918411358470ULL, 0x2C57B3B8EB0BDFC5ULL, 0x6EA7525342E1E956ULL, + 0x72E3DAA0AA188782ULL, 0x30133B4B03F2B111ULL, 0xF7021977F9CCEAA4ULL, 0xB5F2F89C5026DC37ULL, + 0x3BD0BCE5A45A6B5DULL, 0x79205D0E0DB05DCEULL, 0xBE317F32F78E067BULL, 0xFCC19ED95E6430E8ULL, + 0x86B86ED5267CDBD3ULL, 0xC4488F3E8F96ED40ULL, 0x0359AD0275A8B6F5ULL, 0x41A94CE9DC428066ULL, + 0xCF8B0890283E370CULL, 0x8D7BE97B81D4019FULL, 0x4A6ACB477BEA5A2AULL, 0x089A2AACD2006CB9ULL, + 0x14DEA25F3AF9026DULL, 0x562E43B4931334FEULL, 0x913F6188692D6F4BULL, 0xD3CF8063C0C759D8ULL, + 0x5DEDC41A34BBEEB2ULL, 0x1F1D25F19D51D821ULL, 0xD80C07CD676F8394ULL, 0x9AFCE626CE85B507ULL}; + +uint64_t HtmlHelper::crc64(uint8_t *data, size_t length, uint64_t crc) +{ + + crc = crc ^ uint64_t(-1); + for (size_t i = 0; i < length; ++i) + { + uint8_t c = data[i]; + crc = crc64_table[(crc ^ (uint8_t)c) & 0xFF] ^ (crc >> 8); + } + return crc ^ uint64_t(-1); +} +uint64_t HtmlHelper::crc64(const std::string&value, uint64_t crc) +{ + crc = crc ^ uint64_t(-1); + for (auto c: value) + { + crc = crc64_table[(crc ^ (uint8_t)c) & 0xFF] ^ (crc >> 8); + } + return crc ^ uint64_t(-1); +} + + +std::string HtmlHelper::generateEtag(const std::filesystem::path &path) +{ + uint64_t crc = 0; + crc = crc64(path.string()); + auto fTime = std::filesystem::last_write_time(path); + crc = crc64((uint8_t*)&fTime, sizeof(fTime)); + return std::to_string(crc); +} diff --git a/PiPedalCommon/src/include/HtmlHelper.hpp b/PiPedalCommon/src/include/HtmlHelper.hpp index fc0d5cb..0af60f2 100644 --- a/PiPedalCommon/src/include/HtmlHelper.hpp +++ b/PiPedalCommon/src/include/HtmlHelper.hpp @@ -29,6 +29,7 @@ namespace pipedal { class HtmlHelper { public: + static std::string timeToHttpDate(); static std::string timeToHttpDate(time_t time); static std::string timeToHttpDate(std::filesystem::file_time_type time); @@ -57,6 +58,11 @@ public: static std::string SafeFileName(const std::string &name); static std::string HtmlEncode(const std::string& text); + static uint64_t crc64(uint8_t *data, size_t length,uint64_t crc = 0); + static uint64_t crc64(const std::string&value, uint64_t crc = 0); + + static std::string generateEtag(const std::filesystem::path &path); + }; } // namespace. \ No newline at end of file diff --git a/PiPedalCommon/src/util.cpp b/PiPedalCommon/src/util.cpp index 828ccf5..52838ee 100644 --- a/PiPedalCommon/src/util.cpp +++ b/PiPedalCommon/src/util.cpp @@ -218,6 +218,10 @@ bool pipedal::IsSubdirectory(const std::filesystem::path &path, const std::files if ((*i) != (*iPath)) { + if (*i == "" && ++i == basePath.end()) // match xyz/ (trailing /) + { + break; + } return false; } ++iPath; diff --git a/src/Lv2Effect.cpp b/src/Lv2Effect.cpp index 6c0b47c..740a59f 100644 --- a/src/Lv2Effect.cpp +++ b/src/Lv2Effect.cpp @@ -100,7 +100,7 @@ Lv2Effect::Lv2Effect( lilv_node_free(uriNode); { AutoLilvNode bundleUri = lilv_plugin_get_bundle_uri(pPlugin); - char *bundleUriString = lilv_file_uri_parse(bundleUri.AsUri().c_str(), nullptr); + char* bundleUriString = lilv_file_uri_parse(bundleUri.AsUri().c_str(), nullptr); std::string storagePath = pHost_->GetPluginStoragePath(); @@ -108,7 +108,7 @@ Lv2Effect::Lv2Effect( pHost_->GetMapFeature().GetMap(), logFeature.GetLog(), bundleUriString, - storagePath); + storagePath); mapPathFeature.Prepare(&(pHost_->GetMapFeature())); mapPathFeature.SetPluginStoragePath(pHost_->GetPluginStoragePath()); @@ -117,11 +117,12 @@ Lv2Effect::Lv2Effect( const auto &fileProperties = info_->piPedalUI()->fileProperties(); for (const auto &fileProperty : fileProperties) { - if (!fileProperty->resourceDirectory().empty()) - { - mapPathFeature.AddResourceFileMapping({makeAbsolutePath(fileProperty->resourceDirectory(), bundleUriString), - makeAbsolutePath(fileProperty->directory(), pHost_->GetPluginStoragePath())}); - } + fs::path targetPath = fileProperty->directory() / std::filesystem::path(bundleUriString).parent_path().filename(); + mapPathFeature.AddResourceFileMapping({ + bundleUriString, + storagePath / targetPath, + fileProperty->fileTypes() + }); } } @@ -243,14 +244,41 @@ Lv2Effect::Lv2Effect( // now that we've loaded the preset, clear the uri, and save new state // Why? because lilv doesn't provide facilities for reading state. pedalboardItem.lv2State(this->stateInterface->Save()); + RestoreState(pedalboardItem); // reload it with OUR map/unmap handling. pedalboardItem.lilvPresetUri(""); } else { - RestoreState(pedalboardItem); + if (!RestoreState(pedalboardItem)) + { + if (info->hasDefaultState()) + { + // restore the default state. + try + { + // REsTORE from LV2_STATE__state default state. + AutoLilvNode pluginNode = lilv_new_uri(pWorld, info->uri().c_str()); + LilvState *pState = lilv_state_new_from_world(pWorld, pHost->GetMapFeature().GetMap(), pluginNode); + if (pState) + { + if (this->stateInterface) + { + this->stateInterface->RestoreState(pState); + } + lilv_state_free(pState); + } + pedalboardItem.lv2State(this->stateInterface->Save()); + RestoreState(pedalboardItem); // do it with OUR map/unmap file handling. + } + catch (const std::exception &e) + { + Lv2Log::warning(SS("Failed to restore default state for " << info->name() << ": " << e.what())); + } + } + } } } -void Lv2Effect::RestoreState(PedalboardItem &pedalboardItem) +bool Lv2Effect::RestoreState(PedalboardItem &pedalboardItem) { // Restore state if present. if (this->stateInterface) @@ -260,20 +288,18 @@ void Lv2Effect::RestoreState(PedalboardItem &pedalboardItem) if (pedalboardItem.lv2State().isValid_) { this->stateInterface->Restore(pedalboardItem.lv2State()); + return true; } - else - { - // set the state to default state. - auto savedState = this->stateInterface->Save(); - pedalboardItem.lv2State(savedState); - } + return false; } catch (const std::exception &e) { std::string name = pedalboardItem.pluginName(); Lv2Log::warning(SS(name << ": " << e.what())); + return false; } } + return true; } void Lv2Effect::ConnectControlPorts() @@ -1116,4 +1142,3 @@ void Lv2Effect::SetPathPatchProperty(const std::string &propertyUri, const std:: { mainThreadPathProperties[propertyUri] = jsonAtom; } - diff --git a/src/Lv2Effect.hpp b/src/Lv2Effect.hpp index 6fe1117..0dd80e3 100644 --- a/src/Lv2Effect.hpp +++ b/src/Lv2Effect.hpp @@ -61,7 +61,7 @@ namespace pipedal FileBrowserFilesFeature fileBrowserFilesFeature; std::unique_ptr stateInterface; - void RestoreState(PedalboardItem&pedalboardItem); + bool RestoreState(PedalboardItem&pedalboardItem); LogFeature logFeature; std::map patchPropertyPrototypes; diff --git a/src/MapFeature.hpp b/src/MapFeature.hpp index 7c9c38b..1481e8e 100644 --- a/src/MapFeature.hpp +++ b/src/MapFeature.hpp @@ -65,5 +65,8 @@ namespace pipedal { const LV2_URID_Map *GetMap() const { return ↦} LV2_URID_Map *GetMap() { return ↦} + const LV2_URID_Unmap *GetUnmap() const { return &unmap;} + LV2_URID_Unmap *GetUnmap() { return &unmap;} + }; } diff --git a/src/MapPathFeature.cpp b/src/MapPathFeature.cpp index aedd4e7..ca56020 100644 --- a/src/MapPathFeature.cpp +++ b/src/MapPathFeature.cpp @@ -22,9 +22,12 @@ #include #include "json.hpp" #include +#include "PluginHost.hpp" +#include "util.hpp" using namespace pipedal; +namespace fs = std::filesystem; MapPathFeature::MapPathFeature() { @@ -69,6 +72,27 @@ void MapPathFeature::FnFreePath(LV2_State_Free_Path_Handle handle, char* path) return ((MapPathFeature *)handle)->AbsolutePath(abstract_path); } +static std::string CreateMappathLink(const fs::path&sourceFile, const fs::path &resourceDirectory, const fs::path &targetDirectory) +{ + try { + fs::path targetFile = targetDirectory / sourceFile.filename(); + if (fs::exists(targetFile)) { + return targetFile; + } + fs::create_directories(targetDirectory); + fs::create_symlink(sourceFile,targetFile); + return targetFile; + }catch (const std::exception &e) { + Lv2Log::error(SS("Failed to convert resource file to media file. " + << sourceFile + << " -> " + << targetDirectory + << " (" << e.what() << ")" + )); + return sourceFile; + } +} + char *MapPathFeature::AbsolutePath(const char *abstract_path) { if (strlen(abstract_path) == 0) @@ -76,7 +100,27 @@ char *MapPathFeature::AbsolutePath(const char *abstract_path) return strdup(""); } std::filesystem::path t (abstract_path); + std::string extension = t.extension(); if (t.is_absolute()) { + + // Handle bundle files, mapping them to the storage path for this plugin. + for (const auto&mapping: this->resourceFileMappings) + { + if (IsSubdirectory(t,mapping.resourcePath)) + { + bool allowed = false; + for (const auto&fileType: mapping.fileTypes) { + if (fileType.IsValidExtension(extension)) { + allowed = true; + break; + } + } + if (allowed) { + std::string result = CreateMappathLink(t,mapping.resourcePath, mapping.storagePath); + return strdup(result.c_str()); + } + } + } return strdup(abstract_path); } std::filesystem::path result = storagePath / t; diff --git a/src/MapPathFeature.hpp b/src/MapPathFeature.hpp index 655c42f..bd8062d 100644 --- a/src/MapPathFeature.hpp +++ b/src/MapPathFeature.hpp @@ -21,17 +21,24 @@ #include "lv2/state/state.h" #include #include "MapFeature.hpp" +#include +#include +#include +#include "PiPedalUI.hpp" namespace pipedal { + class Lv2PluginInfo; struct ResourceFileMapping { - std::string resourcePath; // absolute path of the resource directory. - std::string storagePath; // absolute path of where resource directory files get placed. + std::filesystem::path resourcePath; // absolute path of the resource directory. + std::filesystem::path storagePath; // absolute path of where resource directory files get placed. + std::vector fileTypes; }; class MapPathFeature { public: MapPathFeature(); + void Prepare(MapFeature* map); void SetPluginStoragePath(const std::string&path) { storagePath = path;} @@ -46,6 +53,8 @@ namespace pipedal const LV2_Feature*GetMakePathFeature() { return &makePathFeature;} const LV2_Feature*GetFreePathFeature() { return &freePathFeature;} private: + + std::shared_ptr pluginInfo; char *AbsolutePath(const char *abstract_path); static char *FnAbsolutePath(LV2_State_Map_Path_Handle handle, const char *abstract_path); diff --git a/src/ModFileTypes.hpp b/src/ModFileTypes.hpp index e6065fd..70ad28a 100644 --- a/src/ModFileTypes.hpp +++ b/src/ModFileTypes.hpp @@ -57,6 +57,7 @@ namespace pipedal const std::vector fileTypesX; // mixture of .ext and mime types. Use fileExtensions instead. const std::set fileExtensions; + }; static const std::vector &ModDirectories(); diff --git a/src/ModGui.cpp b/src/ModGui.cpp index 1ed55ed..0ec6915 100644 --- a/src/ModGui.cpp +++ b/src/ModGui.cpp @@ -439,7 +439,7 @@ json_variant pipedal::MakeModGuiTemplateData( // Feed them no files; they will be added later. json_variant filesObj = json_variant::make_array(); json_variant fileObj = json_variant::make_object(); - fileObj["fileType"] = "{{filetype}}"; // magic tags that we will use to generate files at runtime. + fileObj["filetype"] = "{{filetype}}"; // magic tags that we will use to generate files at runtime. fileObj["fullname"] = "{{fullname}}"; fileObj["basename"] = "{{basename}}"; filesObj.as_array()->push_back(fileObj); diff --git a/src/Pedalboard.cpp b/src/Pedalboard.cpp index ce694ab..9296906 100644 --- a/src/Pedalboard.cpp +++ b/src/Pedalboard.cpp @@ -115,6 +115,18 @@ const ControlValue* PedalboardItem::GetControlValue(const std::string&symbol) co return nullptr; } +bool Pedalboard::SetItemUseModUi(int64_t pedalItemId, bool enabled) +{ + PedalboardItem*item = GetItem(pedalItemId); + if (!item) return false; + if (item->useModUi() != enabled) + { + item->useModUi(enabled); + return true; + } + return false; + +} bool Pedalboard::SetItemEnabled(int64_t pedalItemId, bool enabled) { PedalboardItem*item = GetItem(pedalItemId); @@ -513,6 +525,7 @@ JSON_MAP_BEGIN(PedalboardItem) JSON_MAP_REFERENCE(PedalboardItem,lilvPresetUri) JSON_MAP_REFERENCE(PedalboardItem,pathProperties) JSON_MAP_REFERENCE(PedalboardItem,title) + JSON_MAP_REFERENCE(PedalboardItem,useModUi) JSON_MAP_END() diff --git a/src/Pedalboard.hpp b/src/Pedalboard.hpp index 1dcf00b..8ef6fd9 100644 --- a/src/Pedalboard.hpp +++ b/src/Pedalboard.hpp @@ -99,6 +99,7 @@ public: std::string lilvPresetUri_; std::map pathProperties_; std::string title_; + bool useModUi_ = false; // non persistent state. PropertyMap patchProperties; @@ -128,6 +129,7 @@ public: GETTER_SETTER(stateUpdateCount) GETTER_SETTER_REF(lv2State) GETTER_SETTER_REF(title) + GETTER_SETTER(useModUi) Lv2PluginState&lv2State() { return lv2State_; } // non-const version. GETTER_SETTER_REF(lilvPresetUri) @@ -203,6 +205,7 @@ public: bool SetControlValue(int64_t pedalItemId, const std::string &symbol, float value); bool SetItemTitle(int64_t pedalItemId, const std::string &title); bool SetItemEnabled(int64_t pedalItemId, bool enabled); + bool SetItemUseModUi(int64_t pedalItemId, bool enabled); void SetCurrentSnapshotModified(bool modified); bool IsStructureIdentical(const Pedalboard &other) const; // caan we just send a snapshot-style uddate instead of reloading plugins? All settings are ignored. diff --git a/src/PiPedalModel.cpp b/src/PiPedalModel.cpp index d3b300a..fcc8eb5 100644 --- a/src/PiPedalModel.cpp +++ b/src/PiPedalModel.cpp @@ -617,6 +617,24 @@ void PiPedalModel::UpdateCurrentPedalboard(int64_t clientId, Pedalboard &pedalbo } } +void PiPedalModel::SetPedalboardItemUseModUi(int64_t clientId, int64_t instanceId, bool enabled) +{ + std::lock_guard guard{mutex}; + { + this->pedalboard.SetItemUseModUi(instanceId, enabled); + + // Notify clients. + std::vector t{subscribers.begin(), subscribers.end()}; + for (auto &subscriber : t) + { + subscriber->OnItemUseModUiChanged(clientId, instanceId, enabled); + } + this->SetPresetChanged(clientId, true); + + } +} + + void PiPedalModel::SetPedalboardItemEnable(int64_t clientId, int64_t pedalItemId, bool enabled) { std::lock_guard guard{mutex}; diff --git a/src/PiPedalModel.hpp b/src/PiPedalModel.hpp index 3218b63..0a54af1 100644 --- a/src/PiPedalModel.hpp +++ b/src/PiPedalModel.hpp @@ -59,6 +59,7 @@ namespace pipedal virtual int64_t GetClientId() = 0; virtual void OnItemEnabledChanged(int64_t clientId, int64_t pedalItemId, bool enabled) = 0; + virtual void OnItemUseModUiChanged(int64_t clientId, int64_t pedalItemId, bool enabled) = 0; virtual void OnControlChanged(int64_t clientId, int64_t pedalItemId, const std::string &symbol, float value) = 0; virtual void OnInputVolumeChanged(float value) = 0; virtual void OnOutputVolumeChanged(float value) = 0; @@ -348,6 +349,7 @@ namespace pipedal void RemoveNotificationSubsription(std::shared_ptr pSubscriber); void SetPedalboardItemEnable(int64_t clientId, int64_t instanceId, bool enabled); + void SetPedalboardItemUseModUi(int64_t clientId, int64_t instanceId, bool enabled); void SetControl(int64_t clientId, int64_t pedalItemId, const std::string &symbol, float value); void PreviewControl(int64_t clientId, int64_t pedalItemId, const std::string &symbol, float value); diff --git a/src/PiPedalSocket.cpp b/src/PiPedalSocket.cpp index 32c5ea2..f54c14f 100644 --- a/src/PiPedalSocket.cpp +++ b/src/PiPedalSocket.cpp @@ -428,6 +428,22 @@ JSON_MAP_REFERENCE(PedalboardItemEnabledBody, instanceId) JSON_MAP_REFERENCE(PedalboardItemEnabledBody, enabled) JSON_MAP_END() +class PedalboardItemUseModGuiBody +{ +public: + int64_t clientId_ = -1; + int64_t instanceId_ = -1; + bool useModUi_ = true; + + DECLARE_JSON_MAP(PedalboardItemUseModGuiBody); +}; +JSON_MAP_BEGIN(PedalboardItemUseModGuiBody) +JSON_MAP_REFERENCE(PedalboardItemUseModGuiBody, clientId) +JSON_MAP_REFERENCE(PedalboardItemUseModGuiBody, instanceId) +JSON_MAP_REFERENCE(PedalboardItemUseModGuiBody, useModUi) +JSON_MAP_END() + + class UpdateCurrentPedalboardBody { public: @@ -1303,6 +1319,11 @@ public: pReader->read(&body); model.SetPedalboardItemEnable(body.clientId_, body.instanceId_, body.enabled_); } + else if (message == "setPedalboardItemUseModUi") { + PedalboardItemUseModGuiBody body; + pReader->read(&body); + model.SetPedalboardItemUseModUi(body.clientId_, body.instanceId_, body.useModUi_); + } else if (message == "updateCurrentPedalboard") { { @@ -2221,6 +2242,15 @@ private: body.enabled_ = enabled; Send("onItemEnabledChanged", body); } + virtual void OnItemUseModUiChanged(int64_t clientId, int64_t pedalItemId, bool enabled) + { + PedalboardItemEnabledBody body; + body.clientId_ = clientId; + body.instanceId_ = pedalItemId; + body.enabled_ = enabled; + Send("onUseItemModUiChanged", body); + } + }; std::atomic PiPedalSocketHandler::nextClientId = 0; diff --git a/src/PiPedalUI.cpp b/src/PiPedalUI.cpp index abe46f6..fd3215f 100644 --- a/src/PiPedalUI.cpp +++ b/src/PiPedalUI.cpp @@ -140,6 +140,7 @@ UiFileType::UiFileType(PluginHost *pHost, const LilvNode *node) + UiFileProperty::UiFileProperty(PluginHost *pHost, const LilvNode *node, const std::filesystem::path &resourcePath) { auto pWorld = pHost->getWorld(); @@ -455,6 +456,13 @@ bool UiFileType::IsValidExtension(const std::string&extension) const { return false; } +UiFileType::UiFileType(const std::string&label, const std::string &mimeType,const std::string &fileType) +: label_(label) +, mimeType_(mimeType) +, fileExtension_(fileType) +{ +} + UiFileType::UiFileType(const std::string&label, const std::string &fileType) : label_(label) , fileExtension_(fileType) diff --git a/src/PiPedalUI.hpp b/src/PiPedalUI.hpp index 9a2ef3f..53f274d 100644 --- a/src/PiPedalUI.hpp +++ b/src/PiPedalUI.hpp @@ -75,6 +75,7 @@ namespace pipedal { class PluginHost; + class ModFileTypes; class UiFileType { @@ -87,12 +88,16 @@ namespace pipedal UiFileType() {} UiFileType(PluginHost *pHost, const LilvNode *node); UiFileType(const std::string &label, const std::string &fileType); + UiFileType(const std::string &label, const std::string&mimeType, const std::string &fileType); static std::vector GetArray(PluginHost *pHost, const LilvNode *node, const LilvNode *uri); const std::string &label() const { return label_; } + void label(const std::string &value) { label_ = value; } const std::string &fileExtension() const { return fileExtension_; } + void fileExtension(const std::string &value) { fileExtension_ = value; } const std::string &mimeType() const { return mimeType_; } + void mimeType(const std::string &value) { mimeType_ = value; } bool IsValidExtension(const std::string &extension) const; public: @@ -160,6 +165,8 @@ namespace pipedal const std::vector &fileTypes() const { return fileTypes_; } std::vector &fileTypes() { return fileTypes_; } + void fileTypes(const std::vector &value) { fileTypes_ = value; } + void fileTypes(std::vector &&value) { fileTypes_ = std::move(value); } const std::string &patchProperty() const { return patchProperty_; } diff --git a/src/PluginHost.cpp b/src/PluginHost.cpp index 1f4d2a1..4b09185 100644 --- a/src/PluginHost.cpp +++ b/src/PluginHost.cpp @@ -647,6 +647,37 @@ bool Lv2PluginInfo::HasFactoryPresets(PluginHost *lv2Host, const LilvPlugin *plu return result; } +static std::vector ToPiPedalFileTypes( + const std::set &modFileTypes) +{ + std::vector result; + for (const auto &fileType : modFileTypes) + { + UiFileType uiFileType; + std::string type = fileType; + if (type.find("/") != std::string::npos) // mime type? + { + UiFileType t = UiFileType(type,type,""); + result.push_back(t); + } + else + { + // add a leading dot. + if (!type.starts_with(".")) { + type = "." + type; + } + auto t = UiFileType(SS(type << " file"), type); + result.push_back(t); + } + } + std::sort(result.begin(), result.end(), + [](const UiFileType &left, const UiFileType &right) + { + return left.label() < right.label(); + }); + return result; +} + Lv2PluginInfo::FindWritablePathPropertiesResult Lv2PluginInfo::FindWritablePathProperties(PluginHost *lv2Host, const LilvPlugin *pPlugin) { @@ -710,6 +741,13 @@ Lv2PluginInfo::FindWritablePathProperties(PluginHost *lv2Host, const LilvPlugin // "nam,nammodel" fileTypes = mod__fileTypes.AsString(); } + std::string modFileExtensions; + AutoLilvNode mod__extensionsNode = lilv_world_get(pWorld, propertyUri, lv2Host->lilvUris->mod__supportedExtensions, nullptr); + { + // "wav,flac,mp3" + modFileExtensions = mod__extensionsNode.AsString(); + } + std::string fileExtensions = modFileExtensions; ModFileTypes modFileTypes(fileTypes); // Legacy case: audio_uploads/ exists. @@ -734,6 +772,12 @@ Lv2PluginInfo::FindWritablePathProperties(PluginHost *lv2Host, const LilvPlugin std::string modName = modFileTypes.rootDirectories()[0]; auto modDirectory = modFileTypes.GetModDirectory(modName); fileProperty->directory(modDirectory->pipedalPath); + std::set fileExtensions = modDirectory->fileExtensions; + if (!modFileExtensions.empty()) { + auto extensions = split(modFileExtensions, ','); + fileExtensions = std::set(extensions.begin(), extensions.end()); + } + fileProperty->fileTypes(ToPiPedalFileTypes(fileExtensions)); } } @@ -750,8 +794,11 @@ Lv2PluginInfo::FindWritablePathProperties(PluginHost *lv2Host, const LilvPlugin if (fileProperties.size() != 0) { - std::sort(fileProperties.begin(), fileProperties.end(), [](const UiFileProperty::ptr &left, const UiFileProperty::ptr &right) - { + std::sort( + fileProperties.begin(), + fileProperties.end(), + [](const UiFileProperty::ptr &left, const UiFileProperty::ptr &right) + { // properies with indexes first. int32_t indexL = left->index(); if (indexL < 0) @@ -888,7 +935,7 @@ Lv2PluginInfo::Lv2PluginInfo(PluginHost *lv2Host, LilvWorld *pWorld, const LilvP // a lv2:Parameter? if (lilv_world_ask(pWorld, propertyUri, lv2Host->lilvUris->isA, lv2Host->lilvUris->lv2core__Parameter)) { - Lv2PatchPropertyInfo propertyInfo{lv2Host, propertyUri}; + Lv2PatchPropertyInfo propertyInfo{lv2Host, propertyUri}; propertyInfo.writable(true); patchProperties_.push_back(std::move(propertyInfo)); } @@ -915,18 +962,33 @@ Lv2PluginInfo::Lv2PluginInfo(PluginHost *lv2Host, LilvWorld *pWorld, const LilvP break; } } - if (!found) + if (!found) { if (lilv_world_ask(pWorld, propertyUri, lv2Host->lilvUris->isA, lv2Host->lilvUris->lv2core__Parameter)) { - Lv2PatchPropertyInfo propertyInfo{lv2Host, propertyUri}; + Lv2PatchPropertyInfo propertyInfo{lv2Host, propertyUri}; propertyInfo.readable(true); patchProperties_.push_back(std::move(propertyInfo)); } } } } + // default state. + { + AutoLilvNodes stateNodes = lilv_plugin_get_value(pPlugin, lv2Host->lilvUris->state__state); + if (stateNodes) + { + auto stateNode = lilv_nodes_get_first(stateNodes); + LilvState *defaultState = lilv_state_new_from_world(pWorld, lv2Host->GetMapFeature().GetMap(), stateNode); + if (defaultState) + { + Lv2Log::debug("Plugin %s (%s) has default state.", this->name_.c_str(), this->uri_.c_str()); + this->hasDefaultState_ = true; + lilv_state_free(defaultState); + } + } + } } // Fetch pipedal plugin UI @@ -976,6 +1038,10 @@ Lv2PluginInfo::Lv2PluginInfo(PluginHost *lv2Host, LilvWorld *pWorld, const LilvP ++nOutputs; } } + if (nOutputs == 0 || nInputs == 0) + { + isValid = false; + } this->modGui_ = ModGui::Create(lv2Host, pPlugin); this->is_valid_ = isValid; } @@ -1776,7 +1842,7 @@ Lv2PatchPropertyInfo::Lv2PatchPropertyInfo(PluginHost *pluginHost, const LilvNod { LilvWorld *pWorld = pluginHost->getWorld(); this->uri_ = nodeAsString(propertyUri); - + AutoLilvNode range = lilv_world_get(pWorld, propertyUri, pluginHost->lilvUris->rdfs__range, nullptr); if (range) { @@ -1791,7 +1857,9 @@ Lv2PatchPropertyInfo::Lv2PatchPropertyInfo(PluginHost *pluginHost, const LilvNod if (shortName) { this->shortName_ = shortName.AsString(); - } else { + } + else + { this->shortName_ = this->label_; } @@ -1799,14 +1867,16 @@ Lv2PatchPropertyInfo::Lv2PatchPropertyInfo(PluginHost *pluginHost, const LilvNod this->index_ = indexNode.AsInt(-1); AutoLilvNode modFileTypesNode = lilv_world_get(pWorld, propertyUri, pluginHost->lilvUris->mod__fileTypes, nullptr); - if (modFileTypesNode) { + if (modFileTypesNode) + { std::string strFileTypes = modFileTypesNode.AsString(); - this->fileTypes_ = split(strFileTypes,','); + this->fileTypes_ = split(strFileTypes, ','); } AutoLilvNode modSupportedExtensionsNode = lilv_world_get(pWorld, propertyUri, pluginHost->lilvUris->mod__supportedExtensions, nullptr); - if (modSupportedExtensionsNode) { + if (modSupportedExtensionsNode) + { std::string strSupportedExtensions = modSupportedExtensionsNode.AsString(); - this->supportedExtensions_ = split(strSupportedExtensions,','); + this->supportedExtensions_ = split(strSupportedExtensions, ','); } } @@ -1908,6 +1978,8 @@ json_map::storage_type Lv2PluginInfo::jmap{{ json_map::reference("has_factory_presets", &Lv2PluginInfo::has_factory_presets_), json_map::reference("modGui", &Lv2PluginInfo::modGui_), json_map::reference("patchProperties", &Lv2PluginInfo::patchProperties_), + json_map::reference("hasDefaultState", &Lv2PluginInfo::hasDefaultState_), + }}; json_map::storage_type Lv2PluginClass::jmap{{ diff --git a/src/PluginHost.hpp b/src/PluginHost.hpp index 6e2b995..5b9c202 100644 --- a/src/PluginHost.hpp +++ b/src/PluginHost.hpp @@ -1,4 +1,4 @@ -// Copyright (c) 2022 Robin Davies +// Copyright (c) 2025 Robin E. R. Davies // // Permission is hereby granted, free of charge, to any person obtaining a copy of // this software and associated documentation files (the "Software"), to deal in @@ -417,6 +417,8 @@ namespace pipedal std::vector> ports_; std::vector> port_groups_; std::vector patchProperties_; + + bool hasDefaultState_; bool is_valid_ = false; PiPedalUI::ptr piPedalUI_; @@ -450,6 +452,7 @@ namespace pipedal LV2_PROPERTY_GETSET(hasUnsupportedPatchProperties) LV2_PROPERTY_GETSET(modGui) LV2_PROPERTY_GETSET(patchProperties) + LV2_PROPERTY_GETSET(hasDefaultState) const Lv2PortInfo &getPort(const std::string &symbol) { diff --git a/src/StateInterface.cpp b/src/StateInterface.cpp index 83fe213..a9abc54 100644 --- a/src/StateInterface.cpp +++ b/src/StateInterface.cpp @@ -23,6 +23,7 @@ #include "json.hpp" #include #include +#include "Lv2Log.hpp" using namespace pipedal; @@ -83,6 +84,7 @@ static void CheckState(LV2_State_Status status) case LV2_State_Status::LV2_STATE_SUCCESS: return; default: + case LV2_State_Status::LV2_STATE_ERR_UNKNOWN: lv2Error = "Unknown error."; break; case LV2_State_Status::LV2_STATE_ERR_BAD_TYPE: @@ -126,7 +128,8 @@ Lv2PluginState StateInterface::Save() } catch (const std::exception &e) { - throw std::logic_error(SS("State save failed. " << e.what())); + Lv2Log::debug(SS("State save failed. " << e.what())); + return Lv2PluginState(); // an invalid state. } return std::move(callState.state); diff --git a/src/WebServerMod.cpp b/src/WebServerMod.cpp index e557ac8..9400f0a 100644 --- a/src/WebServerMod.cpp +++ b/src/WebServerMod.cpp @@ -103,6 +103,8 @@ static void setCacheControl(HttpResponse &res, const fs::path &path) res.set("Cache-Control", "public, max-age=31536000"); // 1 month auto lastModified = std::filesystem::last_write_time(path); res.set(HttpField::LastModified, HtmlHelper::timeToHttpDate(lastModified)); + // Set ETag for cache validation + res.set("ETag", pipedal::HtmlHelper::generateEtag(path)); } else { diff --git a/todo.txt b/todo.txt index 7e60566..c217866 100644 --- a/todo.txt +++ b/todo.txt @@ -1,15 +1,34 @@ -check reload after change of LV2 plugins. +Magic zoom. + +request cacheing not working. + +MOD ir uses some scheme to limit the minimum buffer size, which is not implemented in Pipedal. + +MOD ir layout issues. + +MOD gui control should disable, not disappear. + +MOD ir font (cooper-hewitt) + +detect self-set control values to prevent weird jitter in mod ui levels. + +ZAMEq2 Low control not working. + + +Modgui view should be persistent/per-plugin Vu Meters, move rendering to background images, in order to reduce layout overhead. +check reload after change of LV2 plugins. + Remove FullScreenIME in PluginControlView.tsx Exactly one underrun per seek in Toob Player Test autohotspot detection. - +Ellipsis on plugin Title check filetime conversion in gcc 12.2! (missing c++ 20 time conversion functions) AudioFiles.cpp fileTimeToInt64 diff --git a/vite/public/css/modGui.css b/vite/public/css/modGui.css index 2684d65..44587c1 100644 --- a/vite/public/css/modGui.css +++ b/vite/public/css/modGui.css @@ -1,3 +1,44 @@ + +@font-face { + font-family: 'cooper hewitt'; + src: url('../fonts/cooper/cooperhewitt-light-webfont.woff2') format('woff2'); + font-weight: 300; + font-style: normal; + font-display: swap +} + +@font-face { + font-family: 'cooper hewitt'; + src: url('../fonts/cooper/cooperhewitt-lightitalic-webfont.woff2') format('woff2'); + font-weight: 300; + font-style: italic; + font-display: swap +} + +@font-face { + font-family: 'cooper hewitt'; + src: url('../fonts/cooper/cooperhewitt-bookitalic-webfont.woff2') format('woff2'); + font-weight: 400; + font-style: italic; + font-display: swap +} + +@font-face { + font-family: 'cooper hewitt'; + src: url('../fonts/cooper/cooperhewitt-book-webfont.woff2') format('woff2'); + font-weight: 400; + font-style: normal; + font-display: swap +} + +@font-face { + font-family: 'cooper hewitt'; + src: url('../fonts/cooper/cooperhewitt-semibold-webfont.woff2') format('woff2'); + font-weight: 600; + font-style: normal; + font-display: swap +} + .mod-pedal { } @@ -40,6 +81,11 @@ margin-bottom: 20px; } +.mod-pedal-settings .mod-control-group { + margin:0; + padding:0 18px; +} + .ppmod-dial-control-image { width: 48px; height: 48px; @@ -61,4 +107,71 @@ white-space: nowrap; overflow: hidden; } - \ No newline at end of file + + +.mod-pedal .mod-drag-handle { + bottom:0; + left:0; + right:0; + top:0; + cursor:default; /* we dont do drag */ + + position:absolute; + z-index: 0; +} + +.mod-pedal .mod-control-group { + z-index: 20; +} + +.mod-pedal .mod-pedal-input, +.mod-pedal .mod-pedal-output { + z-index:30; +} + + + + +/* mod clearfix hack */ +.mod-pedal .clearfix:before, +.mod-pedal .clearfix:after { + content: "."; + display: block; + height: 0; + overflow: hidden; +} +.mod-pedal.clearfix:after { clear: both; } + +/* enumeration list entries for embedded property browsers. */ + +.ppmod-dotdot-flex { + display: flex; + align-items: center; + flex-flow: row nowrap; +} + +.ppmod-dotdot-text { + white-space: nowrap; + flex: 1 0 auto; + font-size: 0.8em; + padding-right: 8px; +} + +.ppmod-dotdot-path { + white-space: nowrap; + font-size: 0.8em; + flex: 0 1 auto; + overflow: hidden; +} + +.ppmod-dotdot-breadcrumb_link { + display: inline; + white-space: nowrap; + text-decoration: underline +} + +.ppmod-dotdot-breadcrumb_current { + display: inline; + white-space: nowrap; +} + diff --git a/vite/src/pipedal/AppThemed.tsx b/vite/src/pipedal/AppThemed.tsx index bf2eddf..f7e1d1e 100644 --- a/vite/src/pipedal/AppThemed.tsx +++ b/vite/src/pipedal/AppThemed.tsx @@ -813,7 +813,7 @@ export {(!this.state.tinyToolBar) && !this.state.performanceView ? ( - + createStyles({ + pgraph: { + paddingBottom: 16 + } +}); + + +export interface AutoZoomProps extends WithStyles { + leftPad: number, + rightPad: number, + children?: React.ReactNode, + contentReady: boolean, + showZoomed: boolean, + setShowZoomed: (value: boolean) => void; +} + +export interface AutoZoomState { + screenWidth: number, + screenHeight: number, +} + + + +const AutoZoom = withStyles( + class extends ResizeResponsiveComponent { + private model: PiPedalModel; + constructor(props: AutoZoomProps) { + super(props); + + this.model = PiPedalModelFactory.getInstance(); + void this.model; // suppress unused variable warning + + this.state = { + screenWidth: this.windowSize.width, + screenHeight: this.windowSize.height, + }; + } + mounted: boolean = false; + + + + onWindowSizeChanged(width: number, height: number): void { + this.setState({ screenWidth: width, screenHeight: height }); + } + + + componentDidMount() { + super.componentDidMount(); + this.mounted = true; + if (this.normalRef) { + this.updateZoom(this.normalRef); + } + } + componentWillUnmount() { + this.mounted = false; + this.cancelZoomUpdate(); + this.cancelMaximizedZoomUpdate(); + super.componentWillUnmount(); + } + + + componentDidUpdate(prevProps: AutoZoomProps) { + if (prevProps.contentReady !== this.props.contentReady) { + if (this.normalRef) { + this.updateZoom(this.normalRef); + } + if (this.maximizedRef) { + this.updateMaximizedZoom(); + } + } + } + + resizeObserver: ResizeObserver | null = null; + + + calculateZoomedClientRect( + clientRect: Rect, + contentWidth: number, + contentHeight: number, + buttonPadding: number + ): Rect { + + let rightButtonZoom = Math.min((clientRect.width - buttonPadding) / contentWidth, clientRect.height / contentHeight); + let topButtonZoom = Math.min((clientRect.width - buttonPadding) / contentWidth, (clientRect.height - buttonPadding) / contentHeight); + + let effectiveClientRect = clientRect.copy(); + if (rightButtonZoom > topButtonZoom) { + effectiveClientRect.width -= buttonPadding; + } else { + effectiveClientRect.y += buttonPadding; + effectiveClientRect.height -= buttonPadding; + } + + let zoom = Math.min(effectiveClientRect.width / contentWidth, effectiveClientRect.height / contentHeight); + if (zoom > 1) { + zoom = 1; + } + let leftPad = (effectiveClientRect.width - contentWidth * zoom) / 2; + if (leftPad < 0) { + leftPad = 0; + } + let topPad = (effectiveClientRect.height - contentHeight * zoom) / 4; + if (topPad < 0) { + topPad = 0; + } + return new Rect(effectiveClientRect.x + leftPad, effectiveClientRect.y + topPad, contentWidth * zoom, contentHeight * zoom); + } + + updateZoom(ref: HTMLDivElement) { + if (!this.mounted || !ref) { + return; + } + let content = ref.firstElementChild as HTMLElement | null; + + if (content) { + const { leftPad, rightPad } = this.props; + let bounds = new Rect(leftPad, 0, ref.clientWidth - leftPad - rightPad, ref.clientHeight - 8); + let zoomedClientRect = this.calculateZoomedClientRect( + bounds, content.clientWidth, content.clientHeight, 48); + content.style.left = "0px"; + content.style.top = "0px"; + content.style.transformOrigin = "0 0"; // top-left corner + content.style.transform = + `translate(${zoomedClientRect.x}px, ${zoomedClientRect.y}px) scale(${zoomedClientRect.width / content.clientWidth}, ${zoomedClientRect.height / content.clientHeight})`; + + if (this.zoomButton) { + this.zoomButton.style.display = zoomedClientRect.width < content.clientWidth ? "block" : "none"; + let rightSizeSpace = ref.clientWidth - zoomedClientRect.right; + if (rightSizeSpace - rightPad >= 48) { + this.zoomButton.style.top = `${zoomedClientRect.y + 24}px`; + this.zoomButton.style.left = `${zoomedClientRect.right + 4}px`; + } else { + this.zoomButton.style.top = `${zoomedClientRect.y - 48}px`; + this.zoomButton.style.left = `${zoomedClientRect.right - 64 - 12}px`; + } + } + + // save the position of the Mod UI in page coordinates for later zooming. + this.zoomStartRect = zoomedClientRect.copy(); + let refBounds = ref.getBoundingClientRect(); + this.zoomStartRect.x += refBounds.left; + this.zoomStartRect.y += refBounds.top; + } + } + + updateMaximizedZoom() { + let ref = this.maximizedRef; + if (!this.mounted || !ref) { + return; + } + let content = ref.firstElementChild as HTMLElement | null; + + if (content) { + let leftPad = 16; let rightPad = 16; + let topPad = 16, bottomPad = 16; + let bounds: Rect; + bounds = new Rect(leftPad, topPad, ref.clientWidth - leftPad - rightPad, ref.clientHeight - topPad - bottomPad); + + let zoomedClientRect = this.calculateZoomedClientRect( + bounds, content.clientWidth, content.clientHeight, 48); + content.style.left = "0px"; + content.style.top = "0px"; + content.style.transformOrigin = "0 0"; // top-left corner + content.style.transform = `translate(${zoomedClientRect.x}px, ${zoomedClientRect.y}px) scale(${zoomedClientRect.width / content.clientWidth}, ${zoomedClientRect.height / content.clientHeight})`; + //console.log("Zoomed to: ", zoomedClientRect.toString()); + + } + } + + + private animateFrameHandle: number | null = null; + cancelZoomUpdate() { + if (this.animateFrameHandle) { + window.cancelAnimationFrame(this.animateFrameHandle); + this.animateFrameHandle = null; + } + } + requestZoomUpdate() { + if (this.animateFrameHandle) { + return; // already scheduled + } + this.animateFrameHandle = window.requestAnimationFrame(() => { + this.animateFrameHandle = null; + if (this.normalRef) { + this.updateZoom(this.normalRef); + } + }); + } + private maximizedAnimateFrameHandle: number | null = null; + + cancelMaximizedZoomUpdate() { + if (this.maximizedAnimateFrameHandle) { + window.cancelAnimationFrame(this.maximizedAnimateFrameHandle); + this.maximizedAnimateFrameHandle = null; + } + } + requestMaximizedZoomUpdate() { + if (this.maximizedAnimateFrameHandle) { + return; // already scheduled + } + this.maximizedAnimateFrameHandle = window.requestAnimationFrame(() => { + this.maximizedAnimateFrameHandle = null; + if (this.maximizedRef) { + this.updateMaximizedZoom(); + } + }); + } + + + private zoomButton: HTMLElement | null = null; + private normalRef: HTMLDivElement | null = null; + + setNormalRef(ref: HTMLDivElement | null) { + this.normalRef = ref; + if (ref) { + this.zoomButton = ref.querySelector("#maximize-button") as HTMLDivElement | null; + this.resizeObserver = new ResizeObserver(() => { + this.requestZoomUpdate(); + }); + this.resizeObserver.observe(ref); + let child = ref.firstElementChild as HTMLElement | null; + if (child) { + this.resizeObserver.observe(child); + } + this.requestZoomUpdate(); + } else { + if (this.resizeObserver) { + this.resizeObserver.disconnect(); + this.resizeObserver = null; + this.zoomButton = null; + } + this.zoomStartRect = null; + } + } + + + private maximizedRef: HTMLDivElement | null = null; + private maximedResizeObserver: ResizeObserver | null = null; + + setMaximizedRef(ref: HTMLDivElement | null) { + this.maximizedRef = ref; + if (ref) { + this.maximedResizeObserver = new ResizeObserver(() => { + this.requestMaximizedZoomUpdate(); + }); + this.maximedResizeObserver.observe(ref); + let child = ref.firstElementChild as HTMLElement | null; + if (child) { + this.maximedResizeObserver.observe(child); + } + this.requestMaximizedZoomUpdate(); + } else { + if (this.maximedResizeObserver) { + this.maximedResizeObserver.disconnect(); + this.maximedResizeObserver = null; + } + } + } + + private zoomStartRect: Rect | null = null; + + startZoom() { + if (!this.zoomStartRect) { + return; + } + this.props.setShowZoomed(true); + } + + render() { + const classes = withStyles.getClasses(this.props); + void classes; // suppress unused variable warning + return (
{ this.setNormalRef(ref); }}> +
+ {!this.props.showZoomed && this.props.children} +
+
+ { + this.startZoom(); + }} + > + + +
+ {this.props.showZoomed && ( + ({ + zIndex: this.props.showZoomed ? 1101 : -1, + overflow: "clip", + background: theme.palette.mode === 'dark' ? 'rgba(0, 0, 0, 0.9)' : 'rgba(255, 255, 255, 0.9)' + })} + open={this.props.showZoomed} + > +
{ this.setMaximizedRef(ref); }} + style={{ + position: "absolute", left: 0, top: 0, right: 0, bottom: 0, overflow: "hidden", + visibility: this.props.contentReady ? "visible" : "hidden" + }}> + +
+ { + this.props.showZoomed && this.props.children + } +
+ + { + e.stopPropagation(); + this.props.setShowZoomed(false); + }} + > + + +
+
+ )} +
); + } + }, + styles); + +export default AutoZoom; \ No newline at end of file diff --git a/vite/src/pipedal/FontTest.tsx b/vite/src/pipedal/FontTest.tsx index 0356b14..007ddc8 100644 --- a/vite/src/pipedal/FontTest.tsx +++ b/vite/src/pipedal/FontTest.tsx @@ -63,10 +63,6 @@ function FontTest() { {TypeSample({ fontFamily: "Pirulen", cssRef: "/fonts/pirulen/stylesheet.css", fontWeights: [400] })} {TypeSample({ fontFamily: "Questrial", cssRef: "/fonts/questrial/stylesheet.css", fontWeights: [400] })} - {TypeSample({ fontFamily: "Pirulen", cssRef: "/fonts/pirulen/stylesheet.css", fontWeights: [400] })} - {TypeSample({ fontFamily: "Pirulen", cssRef: "/fonts/pirulen/stylesheet.css", fontWeights: [400] })} - {TypeSample({ fontFamily: "Pirulen", cssRef: "/fonts/pirulen/stylesheet.css", fontWeights: [400] })} - ); diff --git a/vite/src/pipedal/GxTunerView.tsx b/vite/src/pipedal/GxTunerView.tsx index 302860a..2a512e4 100644 --- a/vite/src/pipedal/GxTunerView.tsx +++ b/vite/src/pipedal/GxTunerView.tsx @@ -110,6 +110,9 @@ const GxTunerView = item={this.props.item} customization={this} customizationId={this.customizationId} + showModGui={false} + onSetShowModGui= {(instanceId: number, showModGui: boolean) => {}} + />); } }, diff --git a/vite/src/pipedal/JsonAtom.tsx b/vite/src/pipedal/JsonAtom.tsx index 18fd260..330e97f 100644 --- a/vite/src/pipedal/JsonAtom.tsx +++ b/vite/src/pipedal/JsonAtom.tsx @@ -20,118 +20,214 @@ // Utility class for constructing Json atoms. class JsonAtom { - static Property(value: string): any + + static _Bool(value: boolean): any { - return { - "otype_": "Property", - "value": value - }; - } - static Bool(value: boolean): any - { - return { + return new JsonAtom({ "otype_": "Bool", "value": value - }; + }); } - static Int(value: number): any + isBool(): boolean { - return { + return this.isObject() && this.json.otype_ === "Bool"; + } + asBool(): boolean { + if (this.isBool()) { + return this.json.value as boolean; + } + throw new Error("JsonAtom is not a Bool"); + } + static _Int(value: number): JsonAtom + { + return new JsonAtom({ "otype_": "Int", "value": value - }; + }); } - static Long(value: number): any + isInt(): boolean { - return { + return this.isObject() && this.json.otype_ === "Int"; + } + asInt(): number { + if (this.isInt()) { + return this.json.value as number;; + } + throw new Error("JsonAtom is not an Int"); + } + static _Long(value: number): JsonAtom + { + return new JsonAtom({ "otype_": "Long", "value": value - }; + }); } - static Float(value: number): any + + isLong(): boolean { - return value; + return this.isObject() && this.json.otype_ === "Long"; } - static Double(value: number): any + asLong(): number { + if (this.isLong()) { + return this.json.value as number; + } + throw new Error("JsonAtom is not a Long"); + } + static _Float(value: number): JsonAtom { - return { + return new JsonAtom(value); + } + static _Double(value: number): JsonAtom + { + return new JsonAtom({ "otype_": "Double", "value": value - }; + }); } - static String(value: string): any + static _String(value: string): JsonAtom { - return value; + return new JsonAtom(value); } - static Path(value: string): any + isString() : boolean { - return { + return this.json && typeof this.json === 'string'; + } + asString(): string { + if (this.isString()) { + return this.json as string; + } + throw new Error("JsonAtom is not a String"); + } + + static _Path(value: string): JsonAtom + { + return new JsonAtom({ "otype_": "Path", "value": value - }; + }); } - static Uri(value: string): any + isPath(): boolean { - return { + return this.isObject() && this.json.otype_ === "Path"; + } + asPath(): string { + if (this.isPath()) { + return this.json.value as string; + } + throw new Error("JsonAtom is not a Path"); + } + static _Uri(value: string): JsonAtom + { + return new JsonAtom({ "otype_": "URI", "value": value - }; + }); } - static Urid(value: string): any + isUri(): boolean { - return { + return this.isObject() && this.json.otype_ === "URI"; + } + asUri(): string { + if (this.isUri()) { + return this.json.value as string; + } + throw new Error("JsonAtom is not a URI"); + } + static _Urid(value: string): JsonAtom + { + return new JsonAtom({ "otype_": "URID", "value": value - }; + }); } - static Object(type: string): any + isUrid(): boolean { - return { + return this.isObject() && this.json.otype_ === "URID"; + } + asUrid(): string { + if (this.isUrid()) { + return this.json.value as string; + } + throw new Error("JsonAtom is not a URID"); + } + static _Object(type: string): JsonAtom + { + return new JsonAtom({ "otype_": type - }; + }); } - static Tuple(values: any[]): any + isObject(type?: string): boolean { - return { + if (this.json && typeof this.json === 'object' && !Array.isArray(this.json)) { + if (!type) return true; + return type === this.json.otype_; + } + return false; + } + asObject(type?: string): Object { + if (this.isObject(type)) { + return this.json as Object; + } + throw new Error(`JsonAtom is not an Object of type ${type}`); + } + static _Tuple(values: any[]): JsonAtom + { + return new JsonAtom({ "otype_": "Tuple", "value": values - }; + }); } - static IntVector(values: []): any + static _IntVector(values: []): JsonAtom { - return { + return new JsonAtom({ "otype_": "Vector", "vtype_": "Int", "value": values - }; + }); } - static LongVector(values: []): any + static _LongVector(values: []): JsonAtom { - return { + return new JsonAtom({ "otype_": "Vector", "vtype_": "Long", "value": values - }; + }); } - static FloatVector(values: []): any + static _FloatVector(values: []): JsonAtom { - return { + return new JsonAtom({ "otype_": "Vector", "vtype_": "Float", "value": values - }; + }); } - static DoubleVector(values: []): any + static _DoubleVector(values: []): JsonAtom { - return { + return new JsonAtom({ "otype_": "Vector", "vtype_": "Float", "value": values - }; + }); } + constructor(json: any) { + this.json = json; + } + isArray(): boolean { + return this.json && Array.isArray(this.json); + } + isFloat(): boolean { + return this.json && typeof this.json === 'number'; + } + isNull(): boolean { + return this.json === null; + } + asAny(): any { + return this.json; + } + private json: any; }; export default JsonAtom; diff --git a/vite/src/pipedal/Lv2Plugin.tsx b/vite/src/pipedal/Lv2Plugin.tsx index cc7fb19..1256d09 100644 --- a/vite/src/pipedal/Lv2Plugin.tsx +++ b/vite/src/pipedal/Lv2Plugin.tsx @@ -1021,6 +1021,17 @@ export class UiPlugin implements Deserializable { return null; } + getFilePropertyByUri(patchPropertyUri: string): UiFileProperty | null { + for (let i = 0; i < this.fileProperties.length; ++i) + { + let fileProperty = this.fileProperties[i]; + if (fileProperty.patchProperty === patchPropertyUri) { + return fileProperty; + } + } + return null; + } + uri: string = ""; name: string = ""; minorVersion: number = 0; diff --git a/vite/src/pipedal/MainPage.tsx b/vite/src/pipedal/MainPage.tsx index 8c1b960..654006f 100644 --- a/vite/src/pipedal/MainPage.tsx +++ b/vite/src/pipedal/MainPage.tsx @@ -63,6 +63,7 @@ import PipedalUiIcon from './svg/pp_ui.svg?react'; import SnapshotDialog from './SnapshotDialog'; import { css } from '@emotion/react'; +import { setDefaultModGuiPreference } from './ModGuiHost'; const SPLIT_CONTROLBAR_THRESHHOLD = 750; @@ -253,6 +254,7 @@ export const MainPage = this.setState({ pedalboard: value, selectedPedal: selectedItem, + showModUi: value.maybeGetItem(selectedItem)?.useModUi ?? false, selectedSnapshot: value.selectedSnapshot }); } @@ -279,6 +281,14 @@ export const MainPage = this.model.pedalboard.removeOnChangedHandler(this.onPedalboardChanged); super.componentWillUnmount(); } + + componentDidUpdate(prevProps: MainProps, prevState: MainState) { + if (prevState.selectedPedal !== this.state.selectedPedal + || prevState.pedalboard !== this.state.pedalboard + ) { + this.setState({showModUi: this.state.pedalboard.maybeGetItem(this.state.selectedPedal)?.useModUi??false}); + } + } updateResponsive() { this.setState({ splitControlBar: this.getSplitToolbar(), @@ -369,6 +379,15 @@ export const MainPage = } + handleShowModUi() { + let newState = !this.state.showModUi; + this.model.setPedalboardItemUseModUi(this.state.selectedPedal, newState); + let item = this.model.pedalboard.get().maybeGetItem(this.state.selectedPedal); + if (item) { + setDefaultModGuiPreference(item.uri, newState); + } + } + onPedalboardPropertyChanged(instanceId: number, key: string, value: number) { this.model.setPedalboardControl(instanceId, key, value); } @@ -380,13 +399,14 @@ export const MainPage = if (pedalboardItem === null) return ""; return pedalboardItem.uri; } - titleBar(pedalboardItem: PedalboardItem | null,canShowModUi: boolean): React.ReactNode { + titleBar(pedalboardItem: PedalboardItem | null, canShowModUi: boolean): React.ReactNode { let title = ""; let author = ""; let infoPluginUri = ""; let presetsUri = ""; let missing = false; let canEditTitle = false; + let modGuiButtonVisible = false; if (pedalboardItem) { if (pedalboardItem.isEmpty()) { title = ""; @@ -416,6 +436,7 @@ export const MainPage = // if (uiPlugin.description.length > 20) { // } infoPluginUri = uiPlugin.uri; + modGuiButtonVisible = true; } } } @@ -442,20 +463,25 @@ export const MainPage = display: "flex", flexDirection: "row", height: 48, flexWrap: "nowrap", alignItems: "center" }}> -
+
{canEditTitle ? ( { this.handleEditPluginDisplayName(); }} > - - {title} - {this.state.displayAuthor && ( - {author} - )} - +
+ + {title} + {this.state.displayAuthor && ( + {author} + )} + +
) : (
@@ -474,22 +500,28 @@ export const MainPage =
- {canShowModUi && ( -
- - - {this.state.showModUi ? "PiPedal UI" : "MOD UI"} - - Use MOD UI or PiPedal UI for plugin -
- )} + {modGuiButtonVisible && ( +
+ + + {this.state.showModUi ? "PiPedal UI" : "MOD UI"} + + Use MOD UI or PiPedal UI for plugin. + {!canShowModUi && " The current plugin does not provide a MOD user interface."} + +
+ + )} onClick={() => { - this.setState({ showModUi: !this.state.showModUi }); + this.handleShowModUi() }} size="large"> - {!this.state.showModUi ? + {(!this.state.showModUi || !canShowModUi) ? ( ) : ( @@ -498,6 +530,7 @@ export const MainPage = }
+ )}
@@ -698,8 +731,9 @@ export const MainPage = ) : ( - GetControlView(pedalboardItem,this.state.showModUi && canShowModUi, + GetControlView(pedalboardItem, this.state.showModUi && canShowModUi, (instanceId: number, showModGui: boolean) => { + this.model.setPedalboardItemUseModUi(instanceId, showModGui) this.setState({ showModUi: showModGui }); } ) diff --git a/vite/src/pipedal/ModGuiHost.tsx b/vite/src/pipedal/ModGuiHost.tsx index 922a41c..11bbcff 100644 --- a/vite/src/pipedal/ModGuiHost.tsx +++ b/vite/src/pipedal/ModGuiHost.tsx @@ -21,17 +21,21 @@ * SOFTWARE. */ -import { UiPlugin, UiControl, ControlType } from './Lv2Plugin'; +import { UiPlugin, UiControl, ControlType, UiFileProperty } from './Lv2Plugin'; import React from 'react'; import CloseIcon from '@mui/icons-material/Close'; import IconButtonEx from './IconButtonEx'; import Typography from '@mui/material/Typography/Typography'; import ModGuiErrorBoundary from './ModGuiErrorBoundary'; import OkDialog from './OkDialog'; -import {pathParentDirectory} from './FileUtils'; +import { pathFileName, pathParentDirectory } from './FileUtils'; +import JsonAtom from './JsonAtom'; + +import { + PiPedalModel, PiPedalModelFactory, MonitorPortHandle, State, + ListenHandle, FileRequestResult +} from './PiPedalModel'; -import { PiPedalModel, PiPedalModelFactory, MonitorPortHandle, State, - ListenHandle, PatchPropertyListener } from './PiPedalModel'; const RANGE_SCALE = 120; // 120 pixels to move from 0 to 1. const FINE_RANGE_SCALE = RANGE_SCALE * 10; // 1200 pixels to move from 0 to 1. @@ -41,26 +45,50 @@ const WHEEL_RANGE_SCALE = 120 * 20; const FINE_WHEEL_RANGE_SCALE = WHEEL_RANGE_SCALE * 10; const ULTRA_FINE_WHEEL_RANGE_SCALE = WHEEL_RANGE_SCALE * 50; -export interface IModGuiHostSite { - monitorPort: ( - instanceId: number, - symbol: string, - interval: number, - callback: (value: number) => void) => MonitorPortHandle; - unmonitorPort: (handle: MonitorPortHandle) => void; - setPedalboardControl: (instanceId: number, symbol: string, value: number) => void; - monitorPatchProperty( - instanceId: number, - propertyUri: string, - onReceived: PatchPropertyListener - ): ListenHandle; +export type BypassChangedCallback = (value: boolean) => void; + +function HtmlEncode(value: string): string { + value = value.replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + return value; - cancelMonitorPatchProperty(listenHandle: ListenHandle): void; +} + +function htmlEncodeTest() { + let testString = "&<>'\"\\"; + + let encoded = HtmlEncode(testString); + let div = document.createElement("div"); + div.innerHTML = `
${encoded}
`; + let innerDiv = div.children[0] as HTMLDivElement; + if (innerDiv.getAttribute("data-text") !== testString) { + throw new Error("HtmlEncode failed to encode properly: " + encoded); + } + +} + +htmlEncodeTest(); + +function PathToString(json: any): string { + let t = new JsonAtom(json); + if (t.isPath()) { + return t.asPath(); + } + if (t.isString()) { + return t.asString(); + } + return ""; +} + + +function StringToPath(path: string): any { + return JsonAtom._Path(path).asAny(); +} - getPatchProperty(instanceId: number, uri: string): Promise; - setPatchProperty(instanceId: number, uri: string, value: any): Promise -}; export interface ModGuiHostProps { instanceId: number, @@ -69,7 +97,10 @@ export interface ModGuiHostProps { width?: number; height?: number; test?: boolean; - hostSite: IModGuiHostSite; + handleFileSelect: (instanceId: number, propertyUri: string, filePath: string) => void; + onContentReady: (ready: boolean) => void; + + } export interface ModGuiJs { set_port_value: (symbol: string, value: number) => void; @@ -92,7 +123,10 @@ interface CustomSelectPathControlProps { instanceId: number; plugin: UiPlugin; propertyUri: string, - hostSite: IModGuiHostSite; + hostSite: PiPedalModel; + + handleFileSelect: (instanceId: number, propertyUri: string, filePath: string) => void; + }; @@ -107,6 +141,7 @@ class CustomSelectPathControl implements ModGuiControl { this.props = props; } + private isInlineList: boolean = false; requestUpdate() { if (!this.mounted) return; @@ -130,23 +165,149 @@ class CustomSelectPathControl implements ModGuiControl { } } - requestFileUpdate() { + private getFileProperty(): UiFileProperty | null { + for (let property of this.props.plugin.fileProperties) { + if (property.patchProperty === this.props.propertyUri) { + return property; + } + } + return null; + } + + navBreadcrumbText(breadcrumbs: { pathname: string, displayName: string }[]): string { + let breadcrumbText = ""; + for (let i = 0; i < breadcrumbs.length - 1; ++i) { + let breadcrumb = breadcrumbs[i]; + breadcrumbText += " / "; + } + let breadcrumb = breadcrumbs[breadcrumbs.length - 1]; + breadcrumbText += "
" + + HtmlEncode(breadcrumb.displayName) + + "
"; + return breadcrumbText; + } + + makeEnumeratedListItem( + type: string, + basename: string, + fullname: string, + encodebaseName?: boolean + ): HTMLElement { + type = HtmlEncode(type); + fullname = HtmlEncode(fullname); + if (encodebaseName !== false) { + basename = HtmlEncode(basename); + } + let itemHtml = this.enumeratedListItemTemplate + .replace(/{{basename}}/g, basename) + .replace(/{{filetype}}/g, type) + .replace(/{{fullname}}/g, fullname); + let t = document.createElement("div"); + t.innerHTML = itemHtml; + let itemElement = t.firstElementChild as HTMLElement; + return itemElement; } + + fileRequestResult: FileRequestResult | null = null; + + async requestDirectoryUpdate() { + let model = PiPedalModelFactory.getInstance(); + let fileProperty = this.getFileProperty(); + if (!fileProperty) { + return; + } + + let files = await model.requestFileList2(this.navDirectory || "", fileProperty); + this.fileRequestResult = files; + if (!this.mounted) { + return; + } + if (this.enumeratedListContainerElement) { + this.enumeratedListContainerElement.innerHTML = ""; // remove all children. + + if (files.breadcrumbs.length >= 2) { + let parentDirectory = files.breadcrumbs[files.breadcrumbs.length - 2].pathname; + + let dotdotHtml = "
" + + "
[ ../ ]
" + + "
" + + this.navBreadcrumbText(files.breadcrumbs) + + "
"; + + let dotdotElement = this.makeEnumeratedListItem( + "directory", + dotdotHtml, + parentDirectory, + false + ); + dotdotElement.style.setProperty("border-bottom", "1px solid #888"); + this.enumeratedListContainerElement.appendChild(dotdotElement); + } else { + + // mod-role="enumeration-option" mod-filetype="{{filetype}}" mod-parameter-value="{{fullname}}" + let dotdotHtml = "
" + + "
 
" + + "
" + + this.navBreadcrumbText(files.breadcrumbs) + + "
"; + + let dotdotElement = this.makeEnumeratedListItem( + "directory", + dotdotHtml, + "", + false + ); + dotdotElement.style.setProperty("border-bottom", "1px solid #888"); + this.enumeratedListContainerElement.appendChild(dotdotElement); + + } + + for (let file of files.files) { + let displayName = file.displayName; + if (file.isDirectory) { + displayName = '[ ' + displayName + '/ ]'; + } + let fileType = file.isDirectory ? "directory" : "file"; + let itemElement = this.makeEnumeratedListItem( + fileType, + displayName, + file.pathname + ); + this.enumeratedListContainerElement.appendChild(itemElement); + } + this.updateSelection(); + } + + } + + private navDirectory: string | null = null; + + setNavDirectory(navDirectory: string | null) { + if (this.navDirectory !== navDirectory) { + this.navDirectory = navDirectory; + this.requestDirectoryUpdate(); + } + } private pathValue: string | null = null; - private browsePath: string | null = null; setPathValue(value: string) { if (this.pathValue !== value) { this.pathValue = value; - if (this.pathValue !== "") { + if (this.pathValue !== "" || this.navDirectory === null) { let browsePath = pathParentDirectory(value); - if (this.browsePath !== browsePath) { - this.browsePath = browsePath; - this.requestFileUpdate(); - } + this.setNavDirectory(browsePath); } this.requestUpdate(); } + if (this.enumeratedValueElement) { + this.enumeratedValueElement.textContent = pathFileName(this.pathValue); + } } private updateImage() { @@ -175,7 +336,8 @@ class CustomSelectPathControl implements ModGuiControl { } handlePropertyValueChange(value: any) { - + let path: string = PathToString(value); + this.setPathValue(path); } private mounted: boolean = false; @@ -183,29 +345,29 @@ class CustomSelectPathControl implements ModGuiControl { onMounted() { this.mounted = true; this.props.hostSite.monitorPatchProperty(this.props.instanceId, this.props.propertyUri, - (value: any) => { + (instanceId, propertyUri, value) => { this.handlePropertyValueChange(value); } ); this.props.hostSite.getPatchProperty(this.props.instanceId, this.props.propertyUri) - .then((value: any) => { - this.handlePropertyValueChange(value); - }). - catch((error: any) => { - if (error instanceof Error) { - console.error((error as Error).message); - } else { - console.error(error.toString()); - } - console.error(error); - }); + .then((value: any) => { + this.handlePropertyValueChange(value); + }). + catch((error: any) => { + if (error instanceof Error) { + console.error((error as Error).message); + } else { + console.error(error.toString()); + } + this.handlePropertyValueChange(""); + }); this.requestUpdate(); } onUnmount() { if (this.listenHandle) { this.props.hostSite.cancelMonitorPatchProperty(this.listenHandle); - this.listenHandle = null; + this.listenHandle = null; } this.mounted = false; this.cancelRequestUpdate(); @@ -226,40 +388,92 @@ class CustomSelectPathControl implements ModGuiControl { } } - handleSelected(value: string,strFileType: string) - { - this.setPathValue(value); - this.props.hostSite.setPatchProperty(this.props.instanceId, this.props.propertyUri, value); + + getValueElement(): HTMLElement | null { + return null; } + updateSelection() { + if (!this.frameElement) { + return; + } + let valueElement = this.getValueElement(); + if (valueElement) { + /// xxx: update the display value. + if (this.pathValue === null || this.pathValue === "") { + valueElement.textContent = "No file selected"; + return; + } + valueElement.textContent = this.pathValue; + } + if (this.isInlineList && this.enumeratedListContainerElement) { + for (let i = 0; i < this.enumeratedListContainerElement.children.length; i++) { + let child = this.enumeratedListContainerElement.children[i]; + let strValue = child.getAttribute("mod-parameter-value"); + let strType = child.getAttribute("mod-filetype"); + if (strValue === null) return; + if (strValue === this.pathValue && strType !== "directory") { + child.classList.add("selected"); + } else { + child.classList.remove("selected"); + } + }; + } + } handleClick(event: MouseEvent) { if (!this.frameElement) { return; } event.preventDefault(); event.stopPropagation(); - let enumeratedList = this.frameElement.querySelector(".mod-enumerated-list"); - if (enumeratedList) { - let element = enumeratedList as HTMLElement; - this.toggleVisibility(element) - } - if (event.target) { - let target = event.target as HTMLElement; - let role = target.getAttribute("mod-role"); - if (role === "enumeration-option") // a click in the drop-down list? - { - let strValue = target.getAttribute("mod-parameter-value"); - let strFileType = target.getAttribute("mod-filetype"); - if (strValue && strFileType) { - this.handleSelected(strValue,strFileType); - } + let valueElement = this.getValueElement(); + + if (event.target === valueElement) { + // A click on the value element, so launch the file browser. hooboy! + return; + } + if (!event.target) { + return; + } + let target = event.target as HTMLElement; + if (target.getAttribute("mod-role") === "enumeration-option") { + let strValue = target.getAttribute("mod-parameter-value"); + if (strValue === null) return; let strType = target.getAttribute("mod-filetype"); + if (strType === "directory") { + this.setNavDirectory(strValue); + return; } + + this.setPathValue(strValue); + this.updateSelection(); + this.props.hostSite.setPatchProperty(this.props.instanceId, this.props.propertyUri, StringToPath(strValue)); + return; } } private enumeratedListContainerElement: HTMLElement | null = null; private enumeratedListItemTemplate: string = ""; + private enumeratedValueElement: HTMLElement | null = null; + + getValueControlElement(): HTMLElement | null { + if (!this.frameElement) { + return null; + } + return this.frameElement.querySelector('[mod-role=input-parameter-value]') as HTMLElement | null; + } + + async handleValueClick(event: MouseEvent) { + event.preventDefault(); + event.stopPropagation(); + + + this.props.handleFileSelect( + this.props.instanceId, + this.props.propertyUri, + this.pathValue || "" + ); + } attach(frameElement: HTMLElement) { @@ -280,7 +494,15 @@ class CustomSelectPathControl implements ModGuiControl { } } } - void this.enumeratedListItemTemplate; // suppress unused variable warning + this.enumeratedValueElement = this.getValueControlElement(); + + this.isInlineList = this.enumeratedValueElement === null; + if (this.enumeratedValueElement) { + this.enumeratedValueElement.onclick = (event: MouseEvent) => { + this.handleValueClick(event); + } + } + } @@ -362,7 +584,7 @@ class CustomSelectControl implements ModGuiControl { this.frameElement.querySelectorAll('[mod-role=enumeration-option]') .forEach((inputControl: Element) => { let strValue = inputControl.getAttribute("mod-port-value"); - let value = parseFloat(strValue || ""); + let value = parseFloat(strValue || ""); inputControl.classList.remove("selected"); if (value === this.value) { inputControl.classList.add("selected"); @@ -431,8 +653,7 @@ class CustomSelectControl implements ModGuiControl { if (role === "enumeration-option") // a click in the drop-down list? { let strValue = target.getAttribute("mod-port-value"); - if (strValue && strValue !== "") - { + if (strValue && strValue !== "") { let value = parseFloat(strValue); if (!isNaN(value)) { this.setControlValue(value); @@ -455,14 +676,7 @@ class CustomSelectControl implements ModGuiControl { interface BypassLightControlProps { instanceId: number; - pluginControl: UiControl; - onValueChanged: (instanceId: number, symbol: string, value: number) => void; - monitorPort: ( - instanceId: number, - symbol: string, - interval: number, - callback: (value: number) => void) => MonitorPortHandle; - unmonitorPort: (handle: MonitorPortHandle) => void; + model: PiPedalModel; }; function ModMessage(message: string) { @@ -517,23 +731,22 @@ class BypassLightControl implements ModGuiControl { this.frameElement.classList.remove('on', 'off'); this.frameElement.classList.add( - this.value === this.props.pluginControl.min_value ? + this.value ? 'on' : 'off' ); } private mounted: boolean = false; - private monitorHandle: MonitorPortHandle | null = null; + private listenHandle: ListenHandle | null = null; onMounted() { this.mounted = true; - this.monitorHandle = this.props.monitorPort( + this.listenHandle = this.props.model.addPedalboardItemEnabledChangeListener( this.props.instanceId, - this.props.pluginControl.symbol, - 1.0 / 15, - (value: number) => { - if (value != this.value) { - this.setControlValue(value); + (instanceId: number, isEnabled: boolean) => { + let bypass = isEnabled ? 1.0: 0.0; + if (bypass !== this.value) { + this.setControlValue(bypass); } } ); @@ -541,9 +754,9 @@ class BypassLightControl implements ModGuiControl { } onUnmount() { - if (this.monitorHandle) { - this.props.unmonitorPort(this.monitorHandle); - this.monitorHandle = null; + if (this.listenHandle) { + this.props.model.removePedalboardItemEnabledChangeListener(this.listenHandle); + this.listenHandle = null; } this.mounted = false; this.cancelRequestUpdate(); @@ -647,7 +860,7 @@ class FilmstripControl implements ModGuiControl { return; } let range = this.valueToRange(this.value); - this.frameElement.style.transform = `rotate(${rotation * range-rotation/2}deg)`; + this.frameElement.style.transform = `rotate(${rotation * range - rotation / 2}deg)`; return; } @@ -712,9 +925,9 @@ class FilmstripControl implements ModGuiControl { let value = this.isPointerDown ? this.pointerDownValue : this.value; value = this.clampValue(value); - let isHorizontalStrip = - this.filmstripInfo.frameWidth/this.filmstripInfo.frameHeight - < this.filmstripInfo.width/this.filmstripInfo.height; + let isHorizontalStrip = + this.filmstripInfo.frameWidth / this.filmstripInfo.frameHeight + < this.filmstripInfo.width / this.filmstripInfo.height; if (isHorizontalStrip) { let range = this.valueToRange(value); @@ -1059,15 +1272,25 @@ class FilmstripControl implements ModGuiControl { } }; + function ModGuiHost(props: ModGuiHostProps) { - let xmodel: PiPedalModel = PiPedalModelFactory.getInstance(); + let model: PiPedalModel = PiPedalModelFactory.getInstance(); const { plugin, onClose } = props; const [hostDivRef, setHostDivRef] = React.useState(null); const [errorMessage, setErrorMessage] = React.useState(null); + const [contentReady, setContentReady] = React.useState(null); const [modGuiControls] = React.useState([]); - const [ready, setReady] = React.useState(xmodel.state.get() === State.Ready); + const [ready, setReady] = React.useState(model.state.get() === State.Ready); + const [maximizedUi] = React.useState(false); + function updateContentReady(value: boolean) { + if (value !== contentReady) { + setContentReady(value); + props.onContentReady(value); + } + } + if (!plugin.modGui) { return (
@@ -1076,6 +1299,7 @@ function ModGuiHost(props: ModGuiHostProps) { ); } + function addPortClass(element: Element, selector: string, className: string) { let children = element.querySelectorAll(selector); // call addClass to each element that matches the selector @@ -1091,13 +1315,13 @@ function ModGuiHost(props: ModGuiHostProps) { pluginControl: pluginControl, onValueChanged: (instanceId: number, symbol: string, value: number) => { try { - props.hostSite.setPedalboardControl(instanceId, symbol, value); + model.setPedalboardControl(instanceId, symbol, value); } catch (error) { } }, - monitorPort: props.hostSite.monitorPort, - unmonitorPort: props.hostSite.unmonitorPort + monitorPort: model.monitorPort.bind(model), + unmonitorPort: model.unmonitorPort.bind(model) } ); customSelectControl.attach(control as HTMLElement); @@ -1108,6 +1332,20 @@ function ModGuiHost(props: ModGuiHostProps) { } customSelectControl.onMounted(); } + function monitorBypassPort( + instanceId: number, + symbol: string, + interval: number, + callback: (value: number) => void): MonitorPortHandle { + return model.addPedalboardItemEnabledChangeListener( + instanceId, (instanceId,value) => { + callback(value ? 1 : 0); + } + ); + } + function unmonitorBypassPort(handle: MonitorPortHandle) { + model.removePedalboardItemEnabledChangeListener(handle as ListenHandle); + } function createFootswitchControl(control: Element, symbol: string, controlType: ControlType) { let uiControl = new UiControl(); uiControl.symbol = symbol; @@ -1124,14 +1362,24 @@ function ModGuiHost(props: ModGuiHostProps) { filmStrip: "/img/footswitch_strip.png", verticalStrip: true, onValueChanged: (instanceId: number, symbol: string, value: number) => { - try { - props.hostSite.setPedalboardControl(instanceId, symbol, value); - } catch (error) { + if (symbol === "_bypass") { + model.setPedalboardItemEnabled(instanceId, value !== 0); + } else { + try { + model.setPedalboardControl(instanceId, symbol, value); + } catch (error) { + } } }, - monitorPort: props.hostSite.monitorPort, - unmonitorPort: props.hostSite.unmonitorPort + monitorPort: + symbol === "_bypass" ? + monitorBypassPort : + model.monitorPort.bind(model), + unmonitorPort: + symbol == "_bypass" ? + unmonitorBypassPort : + model.unmonitorPort.bind(model) } ); filmstripControl.attach(control as HTMLElement); @@ -1150,9 +1398,10 @@ function ModGuiHost(props: ModGuiHostProps) { } let selectPathControl = new CustomSelectPathControl({ instanceId: props.instanceId, - propertyUri: pathUri, + propertyUri: pathUri, plugin: props.plugin, - hostSite: props.hostSite + hostSite: model, + handleFileSelect: props.handleFileSelect }); selectPathControl.attach(control as HTMLElement); @@ -1160,29 +1409,13 @@ function ModGuiHost(props: ModGuiHostProps) { selectPathControl.onMounted(); } - - function createBypassLightControl(control: Element, symbol: string) { - let uiControl = new UiControl(); - uiControl.symbol = symbol; - uiControl.controlType = ControlType.BypassLight; - uiControl.min_value = 0; - uiControl.max_value = 1; - uiControl.is_logarithmic = false; - uiControl.integer_property = true; + + function createBypassLightControl(control: Element) { let bypassLightControl = new BypassLightControl( { instanceId: props.instanceId, - pluginControl: uiControl, - onValueChanged: (instanceId: number, symbol: string, value: number) => { - try { - props.hostSite.setPedalboardControl(instanceId, symbol, value); - } catch (error) { - - } - }, - monitorPort: props.hostSite.monitorPort, - unmonitorPort: props.hostSite.unmonitorPort + model: model } ); bypassLightControl.attach(control as HTMLElement); @@ -1211,13 +1444,13 @@ function ModGuiHost(props: ModGuiHostProps) { verticalStrip: false, onValueChanged: (instanceId: number, symbol: string, value: number) => { try { - props.hostSite.setPedalboardControl(instanceId, symbol, value); + model.setPedalboardControl(instanceId, symbol, value); } catch (error) { } }, - monitorPort: props.hostSite.monitorPort, - unmonitorPort: props.hostSite.unmonitorPort + monitorPort: model.monitorPort.bind(model), + unmonitorPort: model.unmonitorPort.bind(model) } ); @@ -1284,14 +1517,11 @@ function ModGuiHost(props: ModGuiHostProps) { .forEach((control) => { let symbol = "_bypass"; let controlType = ControlType.OnOffSwitch; - createFootswitchControl(control, symbol, controlType); }); element.querySelectorAll('[mod-role=bypass-light]') .forEach((control) => { - let symbol = "_bypass"; - - createBypassLightControl(control, symbol); + createBypassLightControl(control); }); element.querySelectorAll('[mod-role=input-parameter]') .forEach((control) => { @@ -1303,6 +1533,7 @@ function ModGuiHost(props: ModGuiHostProps) { setErrorMessage(ModMessage(message)); } async function requestContent() { + updateContentReady(false); try { let modGui = plugin.modGui; if (!modGui) { @@ -1321,7 +1552,7 @@ function ModGuiHost(props: ModGuiHostProps) { let encodedUri = encodeURIComponent(props.plugin.uri); let version = props.plugin.minorVersion * 1000 + props.plugin.microVersion; - let resourceUrl = xmodel.modResourcesUrl; + let resourceUrl = model.modResourcesUrl; let queryParams = "?ns=" + encodedUri + "&v=" + version.toString(); let templateUri = resourceUrl + "_/iconTemplate" + queryParams; let cssUri = resourceUrl + "_/stylesheet" + queryParams; @@ -1367,6 +1598,7 @@ function ModGuiHost(props: ModGuiHostProps) { let height = element.clientHeight; hostDivRef.style.width = width + "px"; hostDivRef.style.height = height + "px"; + updateContentReady(true); } catch (error) { setModError("Error loading UI: " + (error instanceof Error ? error.message : String(error))); } @@ -1383,9 +1615,12 @@ function ModGuiHost(props: ModGuiHostProps) { let stateHandler = (state: State) => { setReady(state === State.Ready); } - xmodel.state.addOnChangedHandler(stateHandler) + model.state.addOnChangedHandler(stateHandler) + + return () => { - xmodel.state.removeOnChangedHandler(stateHandler); + model.state.removeOnChangedHandler(stateHandler); + if (tHostDivRef !== null) { // unmount the custom content. let children = tHostDivRef.children; @@ -1396,6 +1631,7 @@ function ModGuiHost(props: ModGuiHostProps) { } } } + updateContentReady(false); let mc = modGuiControls; for (let i = 0; i < mc.length; i++) { let modGuiControl = mc[i]; @@ -1405,12 +1641,12 @@ function ModGuiHost(props: ModGuiHostProps) { }; }, [hostDivRef, plugin, ready]); + return ( { props.onClose(); setErrorMessage(null); }}>
{ @@ -1418,14 +1654,14 @@ function ModGuiHost(props: ModGuiHostProps) { event.stopPropagation(); }} > - onClose()} style={{ - position: "absolute", top: 16, right: 16, - background: "#FFF5", zIndex: 600 - }}> - - - -
+ {maximizedUi && ( + onClose()} style={{ + position: "absolute", top: 16, right: 16, + background: "#FFF5", zIndex: 600 + }}> + + + )}
-
-
- { - setErrorMessage(null); - onClose(); - }} - text={errorMessage ?? ""} - /> + {errorMessage !== null && ( + { + setErrorMessage(null); + onClose(); + }} + text={errorMessage ?? ""} + /> + )} + ); } + +interface ModGuiPluginPreference { + pluginUri: string; + useModGui: boolean; +} + +let modGuiPluginPreferences: ModGuiPluginPreference[] | null = null; + + +function loadModGuiPreferences() { + if (modGuiPluginPreferences === null) { + let prefs = window.localStorage.getItem("modGuiPluginPreferences"); + try { + if (prefs) { + modGuiPluginPreferences = JSON.parse(prefs); + } else { + modGuiPluginPreferences = []; + } + } catch (error) { + console.error("pipedal: Error parsing modGuiPluginPreferences from localStorage: " + String(error)); + modGuiPluginPreferences = []; + } + } +} + +export function getDefaultModGuiPreference(pluginUri: string) { + loadModGuiPreferences(); + if (modGuiPluginPreferences === null) { + modGuiPluginPreferences = []; + } + if (modGuiPluginPreferences) { + let preference = modGuiPluginPreferences.find(p => p.pluginUri === pluginUri); + if (preference) { + return preference.useModGui; + } + } + return false; +} + + +export function setDefaultModGuiPreference(pluginUri: string, useModGui: boolean) { + loadModGuiPreferences(); + if (modGuiPluginPreferences === null) { + modGuiPluginPreferences = []; + } + + let preference = modGuiPluginPreferences.find(p => p.pluginUri === pluginUri); + if (preference) { + preference.useModGui = useModGui; + } else { + modGuiPluginPreferences.push({ pluginUri: pluginUri, useModGui }); + } + try { + window.localStorage.setItem("modGuiPluginPreferences", JSON.stringify(modGuiPluginPreferences)); + } catch(error) { + console.error("pipedal: Error saving modGuiPluginPreferences to localStorage: " + String(error)); + } +} + + export default ModGuiHost; \ No newline at end of file diff --git a/vite/src/pipedal/ModGuiTest.tsx b/vite/src/pipedal/ModGuiTest.tsx deleted file mode 100644 index 1e0d616..0000000 --- a/vite/src/pipedal/ModGuiTest.tsx +++ /dev/null @@ -1,346 +0,0 @@ -/* - * Copyright (c) 2025 Robin E. R. Davies - * All rights reserved. - - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ - -import React from 'react'; -import Divider from '@mui/material/Divider'; -import { PiPedalModel, PiPedalModelFactory, MonitorPortHandle, State, ListenHandle, PatchPropertyListener } from './PiPedalModel'; -import { useTheme } from '@mui/material/styles'; -import CircularProgress from '@mui/material/CircularProgress'; -import LoadPluginDialog from './LoadPluginDialog'; -import ButtonEx from './ButtonEx'; -import Typography from '@mui/material/Typography/Typography'; -import ModGuiHost, { IModGuiHostSite } from './ModGuiHost'; -import { UiPlugin } from './Lv2Plugin'; -import ModGuiErrorBoundary from './ModGuiErrorBoundary'; - -class MyPortHandle implements MonitorPortHandle { - private static nextHandle: number = 1; - constructor() { - this.handle = MyPortHandle.nextHandle++; - } - handle: number; -}; - -type MonitorCallback = (value: number) => void; - -class ValueEntry { - constructor(defaultValue: number) { - this.value = defaultValue; - } - private listeners: { [handle: number]: MonitorCallback } = {}; - - monitor(interval: number, callback: MonitorCallback): MyPortHandle { - let handle = new MyPortHandle(); - this.listeners[handle.handle] = callback; - return handle; - } - unmonitor(handle: MonitorPortHandle) { - let h = (handle as MyPortHandle).handle; - // remove entry h from listeners - if (!(h in this.listeners)) { - throw new Error("Invalid handle: " + h); - } - // remove the callback from the listeners - delete this.listeners[h]; - } - setValue(value: number) { - if (value !== this.value) { - this.value = value; - // notify all listeners - for (let handle in this.listeners) { - this.listeners[handle](value); - } - } - } - value: number; -}; - -class PatchListenerEntry { - constructor(instanceId: number, propertyUri: string) { - this.instanceId = instanceId; - this.propertyUri = propertyUri; - } - instanceId: number; - propertyUri: string; - value: any = ""; - listeners: { handle: number, listener: PatchPropertyListener}[] = [];; -}; - - -class ValueHandler { - - private valueDictionary: { [symbol: string]: ValueEntry } = {}; - private handleToValueEnry: { [handle: number]: ValueEntry } = {}; - - constructor(instanceId: number, plugin: UiPlugin) { - for (let control of plugin.controls) { - this.valueDictionary[control.symbol] = new ValueEntry(control.default_value); - } - this.valueDictionary["_bypass"] = new ValueEntry(0); - } - monitorPort(instanceId: number, symbol: string, interval: number, callback: MonitorCallback): MonitorPortHandle { - let valueEntry = this.valueDictionary[symbol]; - - let portHandle = valueEntry.monitor(interval, callback); - this.handleToValueEnry[(portHandle as MyPortHandle).handle] = valueEntry; - callback(valueEntry.value); - return portHandle; - } - unmonitorPort(handle: MonitorPortHandle): void { - let h = (handle as MyPortHandle).handle; - if (!(h in this.handleToValueEnry)) { - throw new Error("Invalid handle: " + h); - } - let valueEntry = this.handleToValueEnry[h]; - valueEntry.unmonitor(handle); - delete this.handleToValueEnry[h]; - } - setValue(instanceId: number, symbol: string, value: number) { - // add a delay just for funsies. - window.setTimeout(() => { - if (!(symbol in this.valueDictionary)) { - throw new Error("Invalid symbol: " + symbol); - } - let valueEntry = this.valueDictionary[symbol]; - valueEntry.setValue(value); - }, 100); - } - getValue(instanceId: number, symbol: string): number { - if (!(symbol in this.valueDictionary)) { - throw new Error("Invalid symbol: " + symbol); - } - let valueEntry = this.valueDictionary[symbol]; - return valueEntry.value; - } - private nextListenHandle: number = 1; - - - private patchListeners: PatchListenerEntry[] = []; - - monitorPatchProperty(instanceId: number, propertyUri: string, onReceived: PatchPropertyListener): ListenHandle { - let h = this.nextListenHandle++; - for (let entry of this.patchListeners) { - if (entry.instanceId === instanceId && entry.propertyUri === propertyUri) { - // already listening to this property - entry.listeners.push({ handle: h, listener: onReceived }); - onReceived(instanceId,propertyUri,entry.value); - return { _handle: h }; - } - } - let entry = new PatchListenerEntry(instanceId, propertyUri); - this.patchListeners.push(entry); - entry.listeners.push({ handle: h, listener: onReceived }); - onReceived(instanceId, propertyUri, entry.value); - return {_handle: h}; - } - cancelMonitorPatchProperty(listenHandle: ListenHandle): void { - for (let i = 0; i < this.patchListeners.length; ++i) { - let entry = this.patchListeners[i]; - for (let j = 0; j < entry.listeners.length; ++j) { - if (entry.listeners[j].handle === listenHandle._handle) { - // found the listener, remove it - entry.listeners.splice(j, 1); - if (entry.listeners.length === 0) { - // no more listeners, remove the entry - this.patchListeners.splice(i, 1); - } - return; - } - } - } - } - getPatchProperty(instanceId: number, uri: string): Promise { - let promise = new Promise((resolve, reject) => { - window.setTimeout(() => { - for (let entry of this.patchListeners) { - if (entry.instanceId === instanceId && entry.propertyUri === uri) { - window.setTimeout(() => { - // simulate a delay for getting the property - entry.listeners.forEach(l => l.listener(instanceId, uri, entry.value)); - }, 100); - return resolve(entry.value); - } - } - reject(new Error(`Patch property not found: ${uri} for instance ${instanceId}`)); - },50); - }); - return promise; - } - setPatchProperty(instanceId: number, uri: string, value: any): Promise { - // Implementation here - for (let entry of this.patchListeners) { - if (entry.instanceId === instanceId && entry.propertyUri === uri) { - entry.value = value; - // notify all listeners - entry.listeners.forEach(l => l.listener(instanceId, uri, value)); - return Promise.resolve(true); - } - } - // if we reach here, the property was not found - return Promise.reject("Not found."); - } -}; - -function ModGuiTest() { - const [pluginUrl, setPluginUrl] = React.useState(""); - const [uiPlugin, setUiPlugin] = React.useState(null); - const [pluginDialogOpen, setPluginDialogOpen] = React.useState(false); - const [loading, setLoading] = React.useState(true); - const [valueHandler, setValueHandler] = React.useState(null); - - const [modGuiHostSite] = React.useState({ - monitorPort: (instanceId: number, symbol: string, interval: number, callback: (value: number) => void) => { - if (!valueHandler) { - throw new Error("ValueHandler is not set"); - } - return valueHandler.monitorPort(instanceId, symbol, interval, callback); - }, - unmonitorPort: (handle) => { - if (!valueHandler) { - throw new Error("ValueHandler is not set"); - } - valueHandler.unmonitorPort(handle); - }, - setPedalboardControl: (instanceId, symbol, value) => { - if (!valueHandler) { - throw new Error("ValueHandler is not set"); - } - valueHandler.setValue(instanceId, symbol, value); - }, - monitorPatchProperty(instanceId: number, propertyUri: string, onReceived: PatchPropertyListener): ListenHandle { - if (!valueHandler) { - throw new Error("ValueHandler is not set"); - } - - return valueHandler.monitorPatchProperty(instanceId, propertyUri, onReceived); - }, - cancelMonitorPatchProperty: (listenHandle) => { - if (!valueHandler) { - throw new Error("ValueHandler is not set"); - } - valueHandler.cancelMonitorPatchProperty(listenHandle); - }, - getPatchProperty: async (instanceId, uri) => { - if (!valueHandler) { - throw new Error("ValueHandler is not set"); - } - return valueHandler.getPatchProperty(instanceId, uri) as Promise; - }, - setPatchProperty: async (instanceId, uri, value) => { - if (!valueHandler) { - throw new Error("ValueHandler is not set"); - } - return valueHandler.setPatchProperty(instanceId, uri, value) as Promise; - } - }); - - let model: PiPedalModel = PiPedalModelFactory.getInstance(); - - React.useEffect(() => { - - const onStateChange = (state: State) => { - if (state == State.Ready) { - setLoading(false); - } - }; - - model.state.addOnChangedHandler(onStateChange); - - return () => { - model.state.removeOnChangedHandler(onStateChange); - }; - }, []) - - const theme = useTheme(); - - return ( -
- -
- -
- - -
- ModGUI Test - - {pluginUrl ? pluginUrl : "No plugin loaded"} - - setPluginDialogOpen(true)} > - Load - -
- - - - {uiPlugin && ( -
- { - setPluginUrl(""); - setUiPlugin(null); - setValueHandler(null); - }} > - - { - setPluginUrl(""); - setUiPlugin(null); - }} - hostSite={modGuiHostSite} - /> - -
- )} - {pluginDialogOpen && - { - setPluginUrl(url as string); - let uiPlugin = model.getUiPlugin(url); - setUiPlugin(uiPlugin); - if (uiPlugin) { - setValueHandler(new ValueHandler(-1, uiPlugin)); - } - setPluginDialogOpen(false); - }} - onCancel={() => setPluginDialogOpen(false)} - uri={pluginUrl} - modGuiOnly={true} - /> - } -
- ); -} - -export default ModGuiTest; \ No newline at end of file diff --git a/vite/src/pipedal/Pedalboard.tsx b/vite/src/pipedal/Pedalboard.tsx index c18a2cc..a5129ba 100644 --- a/vite/src/pipedal/Pedalboard.tsx +++ b/vite/src/pipedal/Pedalboard.tsx @@ -79,6 +79,8 @@ export class PedalboardItem implements Deserializable { this.lv2State = input.lv2State; this.lilvPresetUri = input.lilvPresetUri; this.pathProperties = input.pathProperties; + this.useModUi = input.useModUi ?? false; + return this; } deserialize(input: any): PedalboardItem { @@ -212,6 +214,7 @@ export class PedalboardItem implements Deserializable { lv2State: [boolean,any] = [false,{}]; lilvPresetUri: string = ""; pathProperties: {[Name: string]: string} = {}; + useModUi: boolean = false; // true if this item should use the mod-ui. }; export class SnapshotValue { diff --git a/vite/src/pipedal/PiPedalModel.tsx b/vite/src/pipedal/PiPedalModel.tsx index 70d26ce..0fda9f3 100644 --- a/vite/src/pipedal/PiPedalModel.tsx +++ b/vite/src/pipedal/PiPedalModel.tsx @@ -44,7 +44,7 @@ import FilePropertyDirectoryTree from './FilePropertyDirectoryTree'; import AudioFileMetadata from './AudioFileMetadata'; import { pathFileName } from './FileUtils'; import { AlsaSequencerConfiguration, AlsaSequencerPortSelection } from './AlsaSequencer'; - +import {getDefaultModGuiPreference} from './ModGuiHost'; export enum State { Loading, @@ -82,6 +82,24 @@ export enum ReconnectReason { HotspotChanging }; +export type PedalboardItemEnabledChangeCallback = (instanceId: number, isEnabled: boolean) => void; +interface PedalboardItemEnabledChangeItem { + handle: number; + instanceId: number; + currentValue: boolean; + onEnabledChanged: PedalboardItemEnabledChangeCallback; +}; + +export type PedalboardItemUseModUiChangeCallback = (instanceId: number, useModUi: boolean) => void; + +interface PedalboardItemUseModUiChangeItem { + handle: number; + instanceId: number; + currentValue: boolean; + onUseModUiChanged: PedalboardItemUseModUiChangeCallback; +}; + + export type ControlValueChangedHandler = (key: string, value: number) => void; export interface FileEntry { @@ -146,7 +164,6 @@ export interface VuUpdateInfo { }; export interface MonitorPortHandle { - }; export interface ControlValueChangedHandle { _ControlValueChangedHandle: number; @@ -574,10 +591,18 @@ export class PiPedalModel //implements PiPedalModel return true; } + private updateEnabledItems(pedalboard: Pedalboard) + { + for (let item of pedalboard.itemsGenerator()) { + this.updatePedalboardItemEnabled(item.instanceId, item.isEnabled); + this.updatePedalboardItemUseModUi(item.instanceId, item.useModUi); + } + } + private setModelPedalboard(pedalboard: Pedalboard) { this.pedalboard.set(pedalboard); this.selectedSnapshot.set(pedalboard.selectedSnapshot); - + this.updateEnabledItems(pedalboard); } onSocketMessage(header: PiPedalMessageHeader, body?: any) { @@ -687,6 +712,13 @@ export class PiPedalModel //implements PiPedalModel itemEnabledBody.enabled, false // No server notification. ); + } else if (message == "onItemUseModUiChanged") { + let itemEnabledBody = body as PedalboardItemEnableBody; + this._setPedalboardItemUseModUi( + itemEnabledBody.instanceId, + itemEnabledBody.enabled, + false // No server notification. + ); } else if (message === "onPedalboardChanged") { let pedalChangedBody = body as PedalboardChangedBody; this.setModelPedalboard(new Pedalboard().deserialize(pedalChangedBody.pedalboard)); @@ -1563,6 +1595,42 @@ export class PiPedalModel //implements PiPedalModel setPedalboardItemEnabled(instanceId: number, value: boolean): void { this._setPedalboardItemEnabled(instanceId, value, true); } + + setPedalboardItemUseModUi(instanceId: number, useModUi: boolean): void { + this._setPedalboardItemUseModUi(instanceId, useModUi, true); + } + + getPedalboardItemUseModUi(instanceId: number): boolean { + if (!this.pedalboard.get().hasItem(instanceId)) return false; + let item = this.pedalboard.get().getItem(instanceId); + return item.useModUi; + } + _setPedalboardItemUseModUi(instanceId: number, useModUi: boolean, notifyServer: boolean): void { + let pedalboard = this.pedalboard.get(); + if (pedalboard === undefined) throw new PiPedalStateError("Pedalboard not ready."); + let newPedalboard = pedalboard.clone(); + let item = newPedalboard.getItem(instanceId); + let changed = useModUi !== item.useModUi; + if (changed) { + item.useModUi = useModUi; + this.setModelPedalboard(newPedalboard); + if (notifyServer) { + let body = { + clientId: this.clientId, + instanceId: instanceId, + useModUi: useModUi + }; + this.webSocket?.send("setPedalboardItemUseModUi", body); + } + } + } + + + getPedalboardItemEnabled(instanceId: number): boolean { + if (!this.pedalboard.get().hasItem(instanceId)) return false; + let item = this.pedalboard.get().getItem(instanceId); + return item.isEnabled; + } private _setPedalboardItemEnabled(instanceId: number, value: boolean, notifyServer: boolean): void { let pedalboard = this.pedalboard.get(); if (pedalboard === undefined) throw new PiPedalStateError("Pedalboard not ready."); @@ -1625,6 +1693,7 @@ export class PiPedalModel //implements PiPedalModel item.lv2State = [false, {}]; item.vstState = ""; item.pathProperties = {}; + item.useModUi = getDefaultModGuiPreference(selectedUri); for (let fileProperty of plugin.fileProperties) { // stringized json for an atom. see AtomConverter.hpp. // null -> we've never seen a value. @@ -1845,6 +1914,7 @@ export class PiPedalModel //implements PiPedalModel } newPedalboard.setItemEmpty(item); + this.setModelPedalboard(newPedalboard); this.updateServerPedalboard(); return item.instanceId; @@ -2210,6 +2280,18 @@ export class PiPedalModel //implements PiPedalModel } getPatchProperty(instanceId: number, uri: string): Promise { let result = new Promise((resolve, reject) => { + let pedalboard = this.pedalboard.get(); + if (pedalboard) { + let item = pedalboard.getItem(instanceId); + if (item) { + if (item.pathProperties.hasOwnProperty(uri)) { + let value = item.pathProperties[uri]; + let jsonValue = JSON.parse(value); + resolve(jsonValue as Type); + } + + } + } if (!this.webSocket) { reject("Socket closed."); } else { @@ -3233,6 +3315,94 @@ export class PiPedalModel //implements PiPedalModel throw new Error("No connection."); } + + private pedalboardItemEnabledChangeListeners: PedalboardItemEnabledChangeItem[] = []; + private pedalboardItemUseModUiChangeListeners: PedalboardItemUseModUiChangeItem[] = []; + + private updatePedalboardItemEnabled(instanceId: number, enabled: boolean) { + for (let listener of this.pedalboardItemEnabledChangeListeners) { + if (listener.instanceId === instanceId) { + if (listener.currentValue !== enabled) { + listener.currentValue = enabled; + listener.onEnabledChanged(instanceId, enabled); + } + } + } + } + + private updatePedalboardItemUseModUi(instanceId: number, useModUi: boolean) { + for (let listener of this.pedalboardItemUseModUiChangeListeners) { + if (listener.instanceId === instanceId) { + if (listener.currentValue !== useModUi) { + listener.currentValue = useModUi; + listener.onUseModUiChanged(instanceId, useModUi); + } + } + } + } + + addPedalboardItemEnabledChangeListener( + instanceId: number, + onEnabledChanged: PedalboardItemEnabledChangeCallback + ) : ListenHandle{ + let handle = ++this.nextListenHandle; + let currentValue = this.getPedalboardItemEnabled(instanceId); + + let entry: PedalboardItemEnabledChangeItem = { + instanceId: instanceId, + handle: handle, + currentValue: currentValue, + onEnabledChanged: onEnabledChanged + }; + + this.pedalboardItemEnabledChangeListeners.push( entry); + + onEnabledChanged(instanceId, currentValue); + + return { _handle: handle }; + } + addPedalboardItemUseModUiChangeListener( + instanceId: number, + onUseModUiChanged : PedalboardItemUseModUiChangeCallback + ) : ListenHandle{ + + let handle = ++this.nextListenHandle; + let currentValue = this.getPedalboardItemUseModUi(instanceId); + + let entry: PedalboardItemUseModUiChangeItem = { + instanceId: instanceId, + handle: handle, + currentValue: currentValue, + onUseModUiChanged: onUseModUiChanged + }; + + this.pedalboardItemUseModUiChangeListeners.push( entry); + + onUseModUiChanged(instanceId, currentValue); + + return { _handle: handle }; + } + + removePedalboardItemEnabledChangeListener(listenHandle: ListenHandle): boolean { + for (let i = 0; i < this.pedalboardItemEnabledChangeListeners.length; ++i) { + if (this.pedalboardItemEnabledChangeListeners[i].handle === listenHandle._handle) { + this.pedalboardItemEnabledChangeListeners.splice(i, 1); + return true; + } + } + return false; + } + + removePedalboardItemUseModUiChangeListener(listenHandle: ListenHandle): boolean { + for (let i = 0; i < this.pedalboardItemUseModUiChangeListeners.length; ++i) { + if (this.pedalboardItemUseModUiChangeListeners[i].handle === listenHandle._handle) { + this.pedalboardItemUseModUiChangeListeners.splice(i, 1); + return true; + } + } + return false; + } + }; let instance: PiPedalModel | undefined = undefined; diff --git a/vite/src/pipedal/PluginControlView.tsx b/vite/src/pipedal/PluginControlView.tsx index 6c3c097..976b9cd 100644 --- a/vite/src/pipedal/PluginControlView.tsx +++ b/vite/src/pipedal/PluginControlView.tsx @@ -1,4 +1,4 @@ -// Copyright (c) 2022 Robin Davies +// Copyright (c) 2025 Robin E. R. Davies // // Permission is hereby granted, free of charge, to any person obtaining a copy of // this software and associated documentation files (the "Software"), to deal in @@ -23,7 +23,9 @@ import WithStyles, { withTheme } from './WithStyles'; import { createStyles } from './WithStyles'; import { css } from '@emotion/react'; -import ModGuiHost, { IModGuiHostSite } from './ModGuiHost'; +import AutoZoom from './AutoZoom'; + +import ModGuiHost from './ModGuiHost'; import { withStyles } from "tss-react/mui"; import { PiPedalModel, PiPedalModelFactory } from './PiPedalModel'; @@ -353,7 +355,9 @@ type PluginControlViewState = { imeInitialHeight: number; showFileDialog: boolean, dialogFileProperty: UiFileProperty, - dialogFileValue: string + dialogFileValue: string, + modGuiContentReady: boolean, + showModGuiZoomed: boolean, }; const PluginControlView = @@ -372,7 +376,9 @@ const PluginControlView = imeInitialHeight: 0, showFileDialog: false, dialogFileProperty: new UiFileProperty(), - dialogFileValue: "" + dialogFileValue: "", + modGuiContentReady: false, + showModGuiZoomed: false } this.onPedalboardChanged = this.onPedalboardChanged.bind(this); @@ -774,36 +780,64 @@ const PluginControlView = } return false; } - makeHostSite(): IModGuiHostSite { - // Yuck. :-( This worked out badly. - let model = this.model; - return { - monitorPort: this.model.monitorPort.bind(model), - unmonitorPort: this.model.unmonitorPort.bind(model), - setPedalboardControl: this.model.setPedalboardControl.bind(model), - monitorPatchProperty: this.model.monitorPatchProperty.bind(model), - cancelMonitorPatchProperty: this.model.cancelMonitorPatchProperty.bind(model), - getPatchProperty: this.model.getPatchProperty.bind(model), - setPatchProperty: this.model.setPatchProperty.bind(model), - }; - } + + handleModGuiFileProperty(instanceId: number, filePropertyUri: string, selectedFile: string) { + let plugin = this.model.getUiPlugin(this.props.item.uri); + if (!plugin) { + throw (new Error("Plugin not found.")); + } + let fileProperty = plugin.getFilePropertyByUri(filePropertyUri); + if (!fileProperty) { + throw (new Error("File property not found.")); + } + this.setState({ + showFileDialog: true, + dialogFileProperty: fileProperty, + dialogFileValue: selectedFile + }); + } renderModGui(): ReactNode { + const classes = withStyles.getClasses(this.props); + void classes; + let uiPlugin = this.model.getUiPlugin(this.props.item.uri); if (!uiPlugin) { return (
); } return ( - { - if (this.props.onSetShowModGui) { - this.props.onSetShowModGui(this.props.instanceId, false); +
+ { this.setState({ showModGuiZoomed: value }); } } - }} - hostSite={this.makeHostSite()} - /> + > + { + if (this.props.onSetShowModGui) { + this.props.onSetShowModGui(this.props.instanceId, false); + } + this.setState({showModGuiZoomed: false}) + }} + handleFileSelect={(instanceId, fileProperty, selectedFile) => { + this.handleModGuiFileProperty( + instanceId, + fileProperty, + selectedFile); + + }} + onContentReady={(value) => { + this.setState({ modGuiContentReady: value }); + }} + /> + +
+ ) } @@ -859,22 +893,22 @@ const PluginControlView = nodes.push(this.midiBindingControl(pedalboardItem)); } return ( -
-
- { - nodes - } - {/* Extra space to allow scrolling right to the end in lascape especially */} - {!this.fullScreen() && ( -
- )} - { - (!this.state.landscapeGrid) && (!this.fullScreen()) && ( -
- ) - } -
+
+
+ { + nodes + } + {/* Extra space to allow scrolling right to the end in lascape especially */} + {!this.fullScreen() && ( +
+ )} + { + (!this.state.landscapeGrid) && (!this.fullScreen()) && ( +
+ ) + }
+
); @@ -895,7 +929,7 @@ const PluginControlView = let vuMeterRClass = this.state.landscapeGrid ? classes.vuMeterRLandscape : classes.vuMeterR; let frameClass = classes.frame; - if (this.fullScreen()) { + if (this.fullScreen() || this.props.showModGui) { frameClass = classes.noScrollFrame; } @@ -920,13 +954,15 @@ const PluginControlView = instanceId={this.props.instanceId} selectedFile={this.state.dialogFileValue} onCancel={() => { - this.setState({ showFileDialog: false }); + this.setState({ + showFileDialog: false + }); }} onApply={(fileProperty, selectedFile) => { this.model.setPatchProperty( this.props.instanceId, fileProperty.patchProperty, - JsonAtom.Path(selectedFile) + JsonAtom._Path(selectedFile).asAny() ) .then(() => { @@ -941,7 +977,7 @@ const PluginControlView = this.model.setPatchProperty( this.props.instanceId, fileProperty.patchProperty, - JsonAtom.Path(selectedFile) + JsonAtom._Path(selectedFile).asAny() ) .then(() => { diff --git a/vite/src/pipedal/Rect.tsx b/vite/src/pipedal/Rect.tsx index 096f377..8ef44d1 100644 --- a/vite/src/pipedal/Rect.tsx +++ b/vite/src/pipedal/Rect.tsx @@ -31,7 +31,7 @@ class Rect { this.x = x; this.y = y; this.width = width; this.height = height; } - toString() { return '{' + this.x + "," + this.y + " " + this.width + "," + this.height +"}"; } + toString() { return '{' + this.x + "," + this.y + " - " + this.width + "," + this.height +"}"; } copy(): Rect { let result = new Rect(); result.x = this.x; @@ -84,6 +84,12 @@ class Rect { this.x += xOffset; this.y += yOffset; } + equals(other: Rect): boolean { + return this.x === other.x && + this.y === other.y && + this.width === other.width && + this.height === other.height; + } } export default Rect; \ No newline at end of file diff --git a/vite/src/pipedal/ScratchClass.tsx b/vite/src/pipedal/ScratchClass.tsx index c6655f8..fbe6a6e 100644 --- a/vite/src/pipedal/ScratchClass.tsx +++ b/vite/src/pipedal/ScratchClass.tsx @@ -1,4 +1,4 @@ -// Copyright (c) 2022 Robin Davies +// Copyright (c) 2025 Robin E. R. Davies // // Permission is hereby granted, free of charge, to any person obtaining a copy of // this software and associated documentation files (the "Software"), to deal in diff --git a/vite/src/pipedal/ToobCabSimView.tsx b/vite/src/pipedal/ToobCabSimView.tsx index caa0e3e..74fc3b4 100644 --- a/vite/src/pipedal/ToobCabSimView.tsx +++ b/vite/src/pipedal/ToobCabSimView.tsx @@ -77,6 +77,9 @@ const ToobCabSimView = item={this.props.item} customization={this} customizationId={this.customizationId} + showModGui={false} + onSetShowModGui= {(instanceId: number, showModGui: boolean) => {}} + />); } }, diff --git a/vite/src/pipedal/ToobInputStageView.tsx b/vite/src/pipedal/ToobInputStageView.tsx index dd37f4c..91321f7 100644 --- a/vite/src/pipedal/ToobInputStageView.tsx +++ b/vite/src/pipedal/ToobInputStageView.tsx @@ -21,14 +21,14 @@ import React from 'react'; import { Theme } from '@mui/material/styles'; import WithStyles from './WithStyles'; -import {createStyles} from './WithStyles'; +import { createStyles } from './WithStyles'; import { withStyles } from "tss-react/mui"; import IControlViewFactory from './IControlViewFactory'; import { PiPedalModelFactory, PiPedalModel } from "./PiPedalModel"; import { PedalboardItem } from './Pedalboard'; -import PluginControlView, { ControlGroup,ControlViewCustomization } from './PluginControlView'; +import PluginControlView, { ControlGroup, ControlViewCustomization } from './PluginControlView'; //import ToobFrequencyResponseView from './ToobFrequencyResponseView'; @@ -47,12 +47,11 @@ interface ToobInputStageState { const ToobInputStageView = withStyles( - class extends React.Component - implements ControlViewCustomization - { + class extends React.Component + implements ControlViewCustomization { model: PiPedalModel; - customizationId: number = 1; + customizationId: number = 1; constructor(props: ToobInputStageProps) { super(props); @@ -64,8 +63,7 @@ const ToobInputStageView = return false; } - modifyControls(controls: (React.ReactNode| ControlGroup)[]): (React.ReactNode| ControlGroup)[] - { + modifyControls(controls: (React.ReactNode | ControlGroup)[]): (React.ReactNode | ControlGroup)[] { return controls; // let group = controls[1] as ControlGroup; // group.controls.splice(0,0, @@ -79,6 +77,9 @@ const ToobInputStageView = item={this.props.item} customization={this} customizationId={this.customizationId} + showModGui={false} + onSetShowModGui={(instanceId: number, showModGui: boolean) => { }} + />); } }, diff --git a/vite/src/pipedal/ToobMLView.tsx b/vite/src/pipedal/ToobMLView.tsx index d4dd702..378b07f 100644 --- a/vite/src/pipedal/ToobMLView.tsx +++ b/vite/src/pipedal/ToobMLView.tsx @@ -21,7 +21,7 @@ import React from 'react'; import { Theme } from '@mui/material/styles'; import WithStyles from './WithStyles'; -import {createStyles} from './WithStyles'; +import { createStyles } from './WithStyles'; import { withStyles } from "tss-react/mui"; @@ -48,7 +48,7 @@ const ToobMLView = class extends React.Component implements ControlViewCustomization { model: PiPedalModel; - gainRef: React.RefObject; + gainRef: React.RefObject; customizationId: number = 1; @@ -119,19 +119,17 @@ const ToobMLView = // } let controlIndex: number = -1; let controlValues = this.props.item.controlValues - for (let i:number = 0; i < controlValues.length; ++i) - { + for (let i: number = 0; i < controlValues.length; ++i) { let ctl: any = controls[i]; - let key: any = ctl?.props?.uiControl?.symbol??""; - - if (key === "gain") - { + let key: any = ctl?.props?.uiControl?.symbol ?? ""; + + if (key === "gain") { controlIndex = i; break; } } - if (controlIndex !== -1 ) { + if (controlIndex !== -1) { let gainControl: React.ReactElement = controls[controlIndex] as React.ReactElement; controls[controlIndex] = (
{gainControl}
); @@ -144,6 +142,9 @@ const ToobMLView = item={this.props.item} customization={this} customizationId={this.customizationId} + showModGui={false} + onSetShowModGui={(instanceId: number, showModGui: boolean) => { }} + />); } }, diff --git a/vite/src/pipedal/ToobPlayerControl.tsx b/vite/src/pipedal/ToobPlayerControl.tsx index ac80320..b36ab68 100644 --- a/vite/src/pipedal/ToobPlayerControl.tsx +++ b/vite/src/pipedal/ToobPlayerControl.tsx @@ -525,7 +525,7 @@ export default function ToobPlayerControl( function onNextTrack() { model.getNextAudioFile(audioFile) .then((file) => { - let json = JsonAtom.Path(file); + let json = JsonAtom._Path(file).asAny(); model.setPatchProperty( props.instanceId, AUDIO_FILE_PROPERTY_URI, @@ -538,7 +538,7 @@ export default function ToobPlayerControl( function onPreviousTrack() { model.getPreviousAudioFile(audioFile) .then((file) => { - let json = JsonAtom.Path(file); + let json = JsonAtom._Path(file).asAny(); model.setPatchProperty( props.instanceId, AUDIO_FILE_PROPERTY_URI, @@ -873,7 +873,7 @@ export default function ToobPlayerControl( model.setPatchProperty( props.instanceId, AUDIO_FILE_PROPERTY_URI, - JsonAtom.Path(selectedFile) + JsonAtom._Path(selectedFile).asAny() ) .then(() => { @@ -888,7 +888,7 @@ export default function ToobPlayerControl( model.setPatchProperty( props.instanceId, fileProperty.patchProperty, - JsonAtom.Path(selectedFile) + JsonAtom._Path(selectedFile).asAny() ) .then(() => { diff --git a/vite/src/pipedal/ToobPlayerView.tsx b/vite/src/pipedal/ToobPlayerView.tsx index 1f40908..751620c 100644 --- a/vite/src/pipedal/ToobPlayerView.tsx +++ b/vite/src/pipedal/ToobPlayerView.tsx @@ -110,14 +110,14 @@ const ToobPlayerView = for (let mixControl of mixPanel.controls) { extraControls.push( ( -
+
{mixControl}
)); } let panel = ( - + ); let result: (React.ReactNode | ControlGroup)[] = []; @@ -133,6 +133,9 @@ const ToobPlayerView = item={this.props.item} customization={this} customizationId={this.customizationId} + showModGui={false} + onSetShowModGui={(instanceId: number, showModGui: boolean) => { }} + /> ); } @@ -146,7 +149,7 @@ class ToobPlayerViewFactory implements IControlViewFactory { uri: string = "http://two-play.com/plugins/toob-player"; Create(model: PiPedalModel, pedalboardItem: PedalboardItem): React.ReactNode { - return (); + return (); } diff --git a/vite/src/pipedal/ToobPowerStage2View.tsx b/vite/src/pipedal/ToobPowerStage2View.tsx index 3d1e8b2..664747d 100644 --- a/vite/src/pipedal/ToobPowerStage2View.tsx +++ b/vite/src/pipedal/ToobPowerStage2View.tsx @@ -179,6 +179,8 @@ const ToobPowerstage2View = item={this.props.item} customization={this} customizationId={this.customizationId} + showModGui={false} + onSetShowModGui= {(instanceId: number, showModGui: boolean) => {}} />); } }, diff --git a/vite/src/pipedal/ToobSpectrumAnalyzerView.tsx b/vite/src/pipedal/ToobSpectrumAnalyzerView.tsx index 103d530..269f958 100644 --- a/vite/src/pipedal/ToobSpectrumAnalyzerView.tsx +++ b/vite/src/pipedal/ToobSpectrumAnalyzerView.tsx @@ -21,14 +21,14 @@ import React from 'react'; import { Theme } from '@mui/material/styles'; import WithStyles from './WithStyles'; -import {createStyles} from './WithStyles'; +import { createStyles } from './WithStyles'; import { withStyles } from "tss-react/mui"; import IControlViewFactory from './IControlViewFactory'; import { PiPedalModelFactory, PiPedalModel } from "./PiPedalModel"; import { PedalboardItem } from './Pedalboard'; -import PluginControlView, { ControlGroup,ControlViewCustomization } from './PluginControlView'; +import PluginControlView, { ControlGroup, ControlViewCustomization } from './PluginControlView'; import ToobSpectrumResponseView from './ToobSpectrumResponseView'; @@ -48,12 +48,11 @@ interface ToobSpectrumAnalyzerState { const ToobSpectrumAnalyzerView = withStyles( - class extends React.Component - implements ControlViewCustomization - { + class extends React.Component + implements ControlViewCustomization { model: PiPedalModel; - customizationId: number = 1; + customizationId: number = 1; constructor(props: ToobSpectrumAnalyzerProps) { super(props); @@ -65,11 +64,10 @@ const ToobSpectrumAnalyzerView = return false; } - modifyControls(controls: (React.ReactNode| ControlGroup)[]): (React.ReactNode| ControlGroup)[] - { - controls.splice(0,0, - ( ) - ); + modifyControls(controls: (React.ReactNode | ControlGroup)[]): (React.ReactNode | ControlGroup)[] { + controls.splice(0, 0, + () + ); return controls; } render() { @@ -78,6 +76,9 @@ const ToobSpectrumAnalyzerView = item={this.props.item} customization={this} customizationId={this.customizationId} + showModGui={false} + onSetShowModGui={(instanceId: number, showModGui: boolean) => { }} + />); } }, diff --git a/vite/src/pipedal/ToobToneStackView.tsx b/vite/src/pipedal/ToobToneStackView.tsx index 055177a..bf7dfd7 100644 --- a/vite/src/pipedal/ToobToneStackView.tsx +++ b/vite/src/pipedal/ToobToneStackView.tsx @@ -21,14 +21,14 @@ import React from 'react'; import { Theme } from '@mui/material/styles'; import WithStyles from './WithStyles'; -import {createStyles} from './WithStyles'; +import { createStyles } from './WithStyles'; import { withStyles } from "tss-react/mui"; import IControlViewFactory from './IControlViewFactory'; -import { PiPedalModelFactory, PiPedalModel,ControlValueChangedHandle } from "./PiPedalModel"; +import { PiPedalModelFactory, PiPedalModel, ControlValueChangedHandle } from "./PiPedalModel"; import { PedalboardItem } from './Pedalboard'; -import PluginControlView, { ControlGroup,ControlViewCustomization } from './PluginControlView'; +import PluginControlView, { ControlGroup, ControlViewCustomization } from './PluginControlView'; import ToobFrequencyResponseView from './ToobFrequencyResponseView'; @@ -47,12 +47,11 @@ interface ToobToneStackState { const ToobToneStackView = withStyles( - class extends React.Component - implements ControlViewCustomization - { + class extends React.Component + implements ControlViewCustomization { model: PiPedalModel; - customizationId: number = 1; + customizationId: number = 1; constructor(props: ToobToneStackProps) { super(props); @@ -61,27 +60,24 @@ const ToobToneStackView = isBaxandall: this.IsBaxandall() } } - IsBaxandall() : boolean { + IsBaxandall(): boolean { return this.props.item.getControl("ampmodel").value === 2.0; } controlValueChangedHandle?: ControlValueChangedHandle; - componentDidMount() - { + componentDidMount() { this.controlValueChangedHandle = this.model.addControlValueChangeListener( this.props.instanceId, - (key,value) => { - if (key === "ampmodel") - { - this.setState({isBaxandall: value === 2.0}); + (key, value) => { + if (key === "ampmodel") { + this.setState({ isBaxandall: value === 2.0 }); } } ); } componentWillUnmount() { - if (this.controlValueChangedHandle) - { + if (this.controlValueChangedHandle) { this.model.removeControlValueChangeListener(this.controlValueChangedHandle); this.controlValueChangedHandle = undefined; } @@ -90,18 +86,16 @@ const ToobToneStackView = return false; } - modifyControls(controls: (React.ReactNode| ControlGroup)[]): (React.ReactNode| ControlGroup)[] - { - if (this.state.isBaxandall) - { - controls.splice(0,0, - ( ) - ); + modifyControls(controls: (React.ReactNode | ControlGroup)[]): (React.ReactNode | ControlGroup)[] { + if (this.state.isBaxandall) { + controls.splice(0, 0, + () + ); } else { - controls.splice(0,0, - ( ) - ); + controls.splice(0, 0, + () + ); } return controls; } @@ -111,6 +105,9 @@ const ToobToneStackView = item={this.props.item} customization={this} customizationId={this.customizationId} + showModGui={false} + onSetShowModGui={(instanceId: number, showModGui: boolean) => { }} + />); } },