From 6ea45f46dfeb326a5915a7d8da9f04c52a8c909f Mon Sep 17 00:00:00 2001 From: "Robin E. R. Davies" Date: Sat, 7 Jun 2025 21:52:23 -0400 Subject: [PATCH] Toob Player UI --- PiPedalCommon/src/HtmlHelper.cpp | 11 + PiPedalCommon/src/include/HtmlHelper.hpp | 3 + PiPedalCommon/src/include/util.hpp | 6 +- PiPedalCommon/src/json.cpp | 2 +- PiPedalCommon/src/util.cpp | 10 + docs/img/ubuntu.jpg | Bin 0 -> 48548 bytes src/AudioFileMetadata.cpp | 2 + src/AudioFileMetadata.hpp | 4 + src/AudioFileMetadataReader.cpp | 12 +- src/AudioFiles.cpp | 179 +++++- src/AudioFiles.hpp | 96 +-- src/AudioFilesDb.cpp | 67 ++- src/AudioFilesDb.hpp | 4 + src/PiPedalModel.cpp | 14 + src/PiPedalModel.hpp | 5 + src/PiPedalSocket.cpp | 67 ++- src/Storage.cpp | 22 +- src/Uri.hpp | 2 +- src/WebServerConfig.cpp | 57 +- todo.txt | 5 +- vite/src/pipedal/AudioFileMetadata.tsx | 22 +- vite/src/pipedal/DraggableButtonBase.tsx | 17 + vite/src/pipedal/FilePropertyDialog.tsx | 201 ++++--- vite/src/pipedal/FileUtils.tsx | 80 +++ vite/src/pipedal/LoopDialog.tsx | 630 ++++++++++++++++++++ vite/src/pipedal/PiPedalModel.tsx | 34 +- vite/src/pipedal/Timebase.tsx | 52 ++ vite/src/pipedal/TimebaseselectorDialog.tsx | 307 ++++++++++ vite/src/pipedal/ToobPlayerControl.tsx | 268 ++++++--- 29 files changed, 1860 insertions(+), 319 deletions(-) create mode 100644 docs/img/ubuntu.jpg create mode 100644 vite/src/pipedal/FileUtils.tsx create mode 100644 vite/src/pipedal/LoopDialog.tsx create mode 100644 vite/src/pipedal/Timebase.tsx create mode 100644 vite/src/pipedal/TimebaseselectorDialog.tsx diff --git a/PiPedalCommon/src/HtmlHelper.cpp b/PiPedalCommon/src/HtmlHelper.cpp index 9944bd5..33c1bb4 100644 --- a/PiPedalCommon/src/HtmlHelper.cpp +++ b/PiPedalCommon/src/HtmlHelper.cpp @@ -39,6 +39,17 @@ std::string HtmlHelper::timeToHttpDate() return timeToHttpDate(time(nullptr)); } + +std::string HtmlHelper::timeToHttpDate(std::filesystem::file_time_type time) +{ + + // Convert to time_t. + std::chrono::system_clock::time_point system_time = std::chrono::clock_cast(time); + + auto time_t_value = std::chrono::system_clock::to_time_t(system_time); + return timeToHttpDate(time_t_value); +} + std::string HtmlHelper::timeToHttpDate(time_t time) { // RFC 7231, IMF-fixdate. diff --git a/PiPedalCommon/src/include/HtmlHelper.hpp b/PiPedalCommon/src/include/HtmlHelper.hpp index b2bd9e5..215f5ac 100644 --- a/PiPedalCommon/src/include/HtmlHelper.hpp +++ b/PiPedalCommon/src/include/HtmlHelper.hpp @@ -21,6 +21,8 @@ #include #include +#include +#include namespace pipedal { @@ -29,6 +31,7 @@ class HtmlHelper { public: static std::string timeToHttpDate(); static std::string timeToHttpDate(time_t time); + static std::string timeToHttpDate(std::filesystem::file_time_type time); static std::string encode_url_segment(const char*pStart, const char*pEnd, bool isQuerySegment = false); diff --git a/PiPedalCommon/src/include/util.hpp b/PiPedalCommon/src/include/util.hpp index 793e187..c4c4216 100644 --- a/PiPedalCommon/src/include/util.hpp +++ b/PiPedalCommon/src/include/util.hpp @@ -71,6 +71,7 @@ namespace pipedal return contains(vector, std::string(value)); } + bool HasWritePermissions(const std::filesystem::path &path); class NoCopy { public: @@ -114,11 +115,12 @@ namespace pipedal return true; } - // C locale to lower. Only does 'A'-'Z'. - std::string ToLower(const std::string&value); std::filesystem::path MakeRelativePath(const std::filesystem::path &path, const std::filesystem::path&parentPath); bool IsSubdirectory(const std::filesystem::path &path, const std::filesystem::path &basePath); + std::string ToLower(const std::string&value); + + } diff --git a/PiPedalCommon/src/json.cpp b/PiPedalCommon/src/json.cpp index c25f5ae..6b6dd73 100644 --- a/PiPedalCommon/src/json.cpp +++ b/PiPedalCommon/src/json.cpp @@ -123,7 +123,7 @@ void json_writer::write(string_view v,bool enforceValidUtf8Encoding) if ((uc >= UTF16_SURROGATE_1_BASE && uc <= UTF16_SURROGATE_1_BASE + UTF16_SURROGATE_MASK) || (uc >= UTF16_SURROGATE_2_BASE && uc <= UTF16_SURROGATE_2_BASE + UTF16_SURROGATE_MASK)) { // MUST not encode UTF16 surrogates in UTF8. - throw_encoding_error(); + // throw_encoding_error(); } if (uc == '"' || uc == '\\') diff --git a/PiPedalCommon/src/util.cpp b/PiPedalCommon/src/util.cpp index 08de1f2..fdf6df2 100644 --- a/PiPedalCommon/src/util.cpp +++ b/PiPedalCommon/src/util.cpp @@ -216,3 +216,13 @@ bool pipedal::IsSubdirectory(const std::filesystem::path &path, const std::files } return true; } + + +bool pipedal::HasWritePermissions(const std::filesystem::path &path) +{ + // posix, but may not work on windows. + // allegedly windows provies an _access function, which is probably a superset of + // access. + return access(path.c_str(), W_OK) == 0; +} + diff --git a/docs/img/ubuntu.jpg b/docs/img/ubuntu.jpg new file mode 100644 index 0000000000000000000000000000000000000000..27e2326cf2b2ea149f0439ad32b73e99b7608697 GIT binary patch literal 48548 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

y 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 Ax&QzG literal 0 HcmV?d00001 diff --git a/src/AudioFileMetadata.cpp b/src/AudioFileMetadata.cpp index d6a6fa6..b39bf86 100644 --- a/src/AudioFileMetadata.cpp +++ b/src/AudioFileMetadata.cpp @@ -33,6 +33,8 @@ JSON_MAP_REFERENCE(AudioFileMetadata, lastModified) JSON_MAP_REFERENCE(AudioFileMetadata, title) JSON_MAP_REFERENCE(AudioFileMetadata, track) JSON_MAP_REFERENCE(AudioFileMetadata, album) +JSON_MAP_REFERENCE(AudioFileMetadata, albumArtist) +JSON_MAP_REFERENCE(AudioFileMetadata, artist) JSON_MAP_REFERENCE(AudioFileMetadata, thumbnailType) JSON_MAP_REFERENCE(AudioFileMetadata, thumbnailFile) diff --git a/src/AudioFileMetadata.hpp b/src/AudioFileMetadata.hpp index e5c04f2..583d2a4 100644 --- a/src/AudioFileMetadata.hpp +++ b/src/AudioFileMetadata.hpp @@ -63,6 +63,8 @@ namespace pipedal { std::string title_; int32_t track_ = -1; // track number, 0-based, -1 if not set std::string album_; + std::string artist_; + std::string albumArtist_; float duration_ = 0; int32_t thumbnailType_ = 0; // 0 = unknown, 1 = embedded, 3 = folder, 4 = none std::string thumbnailFile_; // only when thumbnailType is 3= folder. @@ -80,6 +82,8 @@ namespace pipedal { GETTER_SETTER_REF(title) GETTER_SETTER_REF(track) GETTER_SETTER_REF(album) + GETTER_SETTER_REF(albumArtist) + GETTER_SETTER_REF(artist) GETTER_SETTER(duration) ThumbnailType thumbnailType() const { return (ThumbnailType)thumbnailType_;} void thumbnailType(ThumbnailType value) { thumbnailType_ = (int32_t)value; } diff --git a/src/AudioFileMetadataReader.cpp b/src/AudioFileMetadataReader.cpp index ea87e9d..760e98c 100644 --- a/src/AudioFileMetadataReader.cpp +++ b/src/AudioFileMetadataReader.cpp @@ -144,8 +144,12 @@ static int32_t MetadataTrackToInt(const std::string &track, const std::string &d int trackNumber = std::stoi(track); if (!disc.empty()) { - int discNumber = std::stoi(disc); - trackNumber += discNumber + trackNumber; // e.g. disc 1 track 2 becomes 1002 + if (disc != "" && disc != "1/1" && disc != "1") + { + int discNumber = std::stoi(disc); + trackNumber += discNumber*1000+ trackNumber; // e.g. disc 1 track 2 becomes 1002 + } + } return trackNumber; } @@ -178,8 +182,8 @@ AudioFileMetadata::AudioFileMetadata(const std::filesystem::path &file) { // not all of this data gets used (e.g. DATE, YEAR, ALBUM_ARTIST,TOTALTRACKS). But ... write once. this->album_ = MetadataString(tags, {"ALBUM", "album"}); - //this->artist_ = MetadataString(tags, {"ARTIST", "artist"}); - //this->albumArtist_ = MetadataString(tags, {"ALBUM ARTIST", "album_artist", "album artist"}); + this->artist_ = MetadataString(tags, {"ARTIST", "artist"}); + this->albumArtist_ = MetadataString(tags, {"ALBUM ARTIST", "album_artist", "album artist"}); this->title_ = MetadataString(tags, {"TITLE", "title"}); //this->date_ = MetadataString(tags, {"DATE", "date"}); //this->year_ = MetadataString(tags, {"YEAR", "year"}); diff --git a/src/AudioFiles.cpp b/src/AudioFiles.cpp index a51ca00..0043925 100644 --- a/src/AudioFiles.cpp +++ b/src/AudioFiles.cpp @@ -30,6 +30,9 @@ #include "MimeTypes.hpp" #include #include "AudioFilesDb.hpp" +#include "Lv2Log.hpp" +#include "ss.hpp" +#include "util.hpp" #undef _GLIBCXX_DEBUG // Ensure we are not in debug mode, as this file is not compatible with it. #include "SQLiteCpp/SQLiteCpp.h" @@ -48,7 +51,7 @@ void AudioDirectoryInfo::SetResourceDirectory(const std::filesystem::path &path) { resourceDirectory = path; } -std::filesystem::path AudioDirectoryInfo::GetTemporaryDirectory() const +std::filesystem::path AudioDirectoryInfo::GetTemporaryDirectory() { if (temporaryDirectory.empty()) { @@ -56,7 +59,7 @@ std::filesystem::path AudioDirectoryInfo::GetTemporaryDirectory() const } return temporaryDirectory; } -std::filesystem::path AudioDirectoryInfo::GetResourceDirectory() const +std::filesystem::path AudioDirectoryInfo::GetResourceDirectory() { if (resourceDirectory.empty()) { @@ -74,7 +77,9 @@ namespace static int64_t fileTimeToInt64(const fs::file_time_type &fileTime) { - return fileTime.time_since_epoch().count(); + std::chrono::system_clock::time_point system_time = std::chrono::clock_cast(fileTime); + auto result = std::chrono::duration_cast(system_time.time_since_epoch()).count(); + return result; } static int64_t GetLastWriteTime(const fs::path &file) { @@ -91,9 +96,15 @@ namespace class AudioDirectoryInfoImpl : public AudioDirectoryInfo { public: - AudioDirectoryInfoImpl(const std::string &path) - : path(path) + AudioDirectoryInfoImpl(const std::filesystem::path &path, const std::filesystem::path&indexPath) + : path(path), indexPath( + (indexPath.empty() ? path: indexPath) / ".index.pipedal" + ) { + if (this->indexPath.parent_path() != path) + { + fs::create_directories(this->indexPath.parent_path()); + } } virtual ~AudioDirectoryInfoImpl() {} @@ -110,7 +121,16 @@ namespace virtual std::string GetNextAudioFile(const std::string &fileNameOnly) override; virtual std::string GetPreviousAudioFile(const std::string &fileNameOnly) override; + virtual void MoveAudioFile( + const std::string &directory, + int32_t fromPosition, + int32_t toPosition) override; + private: + std::vector QueryTracks(); + + + bool opened = false; std::filesystem::path indexPath; void OpenAudioDb(); ThumbnailTemporaryFile GetUnindexedThunbnail(const std::string &fileNameOnly, int32_t width, int32_t height); @@ -119,7 +139,6 @@ namespace fs::path path; using id_t = int64_t; - std::vector QueryTracks(); std::vector UpdateDbFiles(); void DbSetThumbnailType( @@ -137,14 +156,23 @@ namespace void UpdateMetadata(DbFileInfo *dbFile); static constexpr int DB_VERSION = 1; std::filesystem::path GetFolderFile() const; + }; } - -AudioDirectoryInfo::Ptr AudioDirectoryInfo::Create(const std::string &path) +AudioDirectoryInfo::Ptr AudioDirectoryInfo::Create(const std::filesystem::path &path, const std::filesystem::path&indexPath) { - return std::shared_ptr( - new AudioDirectoryInfoImpl(path)); + return std::make_shared(path,indexPath); +} + + +std::vector AudioDirectoryInfoImpl::QueryTracks() +{ + if (audioFilesDb) + { + return audioFilesDb->QueryTracks(); + } + return std::vector(); } std::vector AudioDirectoryInfoImpl::GetFiles() @@ -160,14 +188,6 @@ std::vector AudioDirectoryInfoImpl::GetFiles() return metadataResults; } -std::vector AudioDirectoryInfoImpl::QueryTracks() -{ - if (audioFilesDb) - { - return audioFilesDb->QueryTracks(); - } - return std::vector(); -} static bool isAudioExtension(const std::string &extension) { return MimeTypes::instance().AudioExtensions().contains(extension); @@ -340,12 +360,6 @@ std::vector AudioDirectoryInfoImpl::UpdateDbFiles() } } - if (!updateRequired && newFiles.empty()) - { - // Nothing to do. - return dbFiles; - } - Locale::ptr locale = Locale::GetInstance(); Collator::ptr collator = locale->GetCollator(); if (!HasPositionInfo(dbFiles)) @@ -415,7 +429,8 @@ static std::vector COVER_ART_FILES = { "AlbumArt.jpg", "albumArt.jpg", "Frontcover.jpg", - "AlbumArtSmall.jpg"}; + "AlbumArtSmall.jpg" // windows media player. +}; fs::path AudioDirectoryInfoImpl::GetFolderFile() const { @@ -446,7 +461,7 @@ void AudioDirectoryInfoImpl::DbSetThumbnailType( } } void AudioDirectoryInfoImpl::DbSetThumbnailType( - const std::string&fileNameOnly, + const std::string &fileNameOnly, ThumbnailType thumbnailType, const std::string &thumbnailFile, int64_t thumbnailLastModified) @@ -614,6 +629,8 @@ void AudioDirectoryInfoImpl::UpdateMetadata(DbFileInfo *dbFile) dbFile->track(metadata.track()); dbFile->duration(metadata.duration()); dbFile->album(metadata.album()); + dbFile->artist(metadata.artist()); + dbFile->albumArtist(metadata.albumArtist()); dbFile->lastModified(GetLastWriteTime(file)); dbFile->duration(metadata.duration()); } @@ -656,8 +673,17 @@ size_t AudioDirectoryInfoImpl::TestGetNumberOfThumbnails() void AudioDirectoryInfoImpl::OpenAudioDb() { - if (!this->audioFilesDb) { - this->audioFilesDb = std::make_shared(this->path, indexPath); + + if (!this->audioFilesDb) + { + try + { + this->audioFilesDb = std::make_shared(this->path, indexPath); + } + catch (const SQLite::Exception &e) + { + Lv2Log::debug("Can't create .index.pipedal: %s - %s", e.what(), this->path.c_str()); + } } } @@ -669,7 +695,7 @@ ThumbnailTemporaryFile AudioDirectoryInfoImpl::DefaultThumbnailTemporaryFile() return tempFile; } -std::string AudioDirectoryInfoImpl::GetNextAudioFile(const std::string &fileNameOnly) +std::string AudioDirectoryInfoImpl::GetNextAudioFile(const std::string &fileNameOnly) { auto files = this->GetFiles(); if (files.empty()) @@ -692,7 +718,7 @@ std::string AudioDirectoryInfoImpl::GetNextAudioFile(const std::string &fileName } return ""; } -std::string AudioDirectoryInfoImpl::GetPreviousAudioFile(const std::string &fileNameOnly) +std::string AudioDirectoryInfoImpl::GetPreviousAudioFile(const std::string &fileNameOnly) { auto files = this->GetFiles(); if (files.empty()) @@ -715,3 +741,96 @@ std::string AudioDirectoryInfoImpl::GetPreviousAudioFile(const std::string &file } return ""; } + +void AudioDirectoryInfoImpl::MoveAudioFile( + const std::string &directory, + int32_t fromPosition, + int32_t toPosition) +{ + OpenAudioDb(); + if (!audioFilesDb) + { + throw std::runtime_error("Directory is not writable."); + } + auto files = this->UpdateDbFiles(); + if (directory.empty() || fromPosition < 0 || toPosition < 0 || + fromPosition >= static_cast(files.size()) || + toPosition > static_cast(files.size())) + { + throw std::invalid_argument("Invalid arguments for MoveAudioFile"); + } + std::shared_ptr transaction; + if (audioFilesDb) + { + transaction = audioFilesDb->transaction(); + } + + if (fromPosition == toPosition) + { + return; // No change needed. + } + if (fromPosition > toPosition) + { + auto t = files[fromPosition]; + files.erase(files.begin() + fromPosition); + files.insert(files.begin() + toPosition, t); + } + else + { + // fromPosition < toPosition + auto t = files[fromPosition]; + files.erase(files.begin() + fromPosition); + files.insert(files.begin() + toPosition, t); // insert before the toPosition. + } + for (size_t i = 0; i < files.size(); ++i) + { + auto &file = files[i]; + if (file.position() != static_cast(i)) + { + file.position(static_cast(i)); + if (this->audioFilesDb) + { + audioFilesDb->UpdateFilePosition(file.idFile(), file.position()); + } + else + { + throw std::runtime_error("Directory is not writable."); + } + } + } + transaction->commit(); +} + + +namespace pipedal { + std::filesystem::path IndexPathToShadowIndexPath(const std::filesystem::path &audioRootDirectory, const std::filesystem::path &path) + { + std::filesystem::path relativePath = MakeRelativePath(path, audioRootDirectory); + if (relativePath.is_absolute()) + { + throw std::runtime_error(SS("Can't write to path " << path << ".")); + }; + return audioRootDirectory / "shadow_indexes" / relativePath; + } + + // recover the original path fromthe shadow index path. + std::filesystem::path ShadowIndexPathToIndexPath(const std::filesystem::path &audioRootDirectory, const std::filesystem::path &path) + { + auto shadowIndexesPath = audioRootDirectory / "shadow_indexes"; + std::filesystem::path relativePath = MakeRelativePath(path, shadowIndexesPath); + if (relativePath.is_absolute()) + { + throw std::runtime_error(SS("Path must start with " << shadowIndexesPath)); + } + return audioRootDirectory / relativePath; // recover the original path. + } + + std::filesystem::path GetShadowIndexDirectory(const std::filesystem::path &audioRootDirectory, const std::filesystem::path &path) + { + if (HasWritePermissions(path)) + { + return ""; // use default index path. + } + return IndexPathToShadowIndexPath(audioRootDirectory, path); + } +} diff --git a/src/AudioFiles.hpp b/src/AudioFiles.hpp index 0f54295..f3c1945 100644 --- a/src/AudioFiles.hpp +++ b/src/AudioFiles.hpp @@ -8,10 +8,10 @@ * 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, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE @@ -21,7 +21,7 @@ * SOFTWARE. */ -#pragma once +#pragma once #include #include @@ -31,75 +31,97 @@ #include "AudioFileMetadata.hpp" #include "TemporaryFile.hpp" -namespace pipedal { +namespace pipedal +{ - class ThumbnailTemporaryFile: public TemporaryFile { + class ThumbnailTemporaryFile : public TemporaryFile + { private: // Private constructor to enforce the use of CreateTemporaryFile or the other constructor. - explicit ThumbnailTemporaryFile(const std::filesystem::path&temporaryDirectory); + explicit ThumbnailTemporaryFile(const std::filesystem::path &temporaryDirectory); + public: using super = TemporaryFile; ThumbnailTemporaryFile() = default; - ThumbnailTemporaryFile(const ThumbnailTemporaryFile&) = delete; - ThumbnailTemporaryFile(ThumbnailTemporaryFile&&) = default; - ThumbnailTemporaryFile&operator=(const ThumbnailTemporaryFile&) = delete; - ThumbnailTemporaryFile&operator=(ThumbnailTemporaryFile&&) = default; -public: - static ThumbnailTemporaryFile CreateTemporaryFile(const std::filesystem::path&tempDirectory,const std::string &mimeType); - static ThumbnailTemporaryFile FromFile(const std::filesystem::path&filePath,const std::string &mimeType); -public: + ThumbnailTemporaryFile(const ThumbnailTemporaryFile &) = delete; + ThumbnailTemporaryFile(ThumbnailTemporaryFile &&) = default; + ThumbnailTemporaryFile &operator=(const ThumbnailTemporaryFile &) = delete; + ThumbnailTemporaryFile &operator=(ThumbnailTemporaryFile &&) = default; - void SetNonDeletedPath(const std::filesystem::path&path, const std::string&mimeType = "audio/mpeg") { + public: + static ThumbnailTemporaryFile CreateTemporaryFile(const std::filesystem::path &tempDirectory, const std::string &mimeType); + static ThumbnailTemporaryFile FromFile(const std::filesystem::path &filePath, const std::string &mimeType); + + public: + void SetNonDeletedPath(const std::filesystem::path &path, const std::string &mimeType = "audio/mpeg") + { TemporaryFile::SetNonDeletedPath(path); // Set the MIME type for audio files. this->mimeType = mimeType; // Default MIME type, can be set later. } - void Attach(const std::filesystem::path&path, const std::string&mimeType); + void Attach(const std::filesystem::path &path, const std::string &mimeType); - std::string GetMimeType() { + std::string GetMimeType() + { return mimeType; } - void SetMimeType(const std::string&mimeType) { + void SetMimeType(const std::string &mimeType) + { this->mimeType = mimeType; } - private: + + private: std::string mimeType = "image/jpeg"; // Default MIME type, can be set later. - }; - class AudioDirectoryInfo { + class AudioDirectoryInfo + { protected: - AudioDirectoryInfo() { } - virtual ~AudioDirectoryInfo() { } + AudioDirectoryInfo() {} + virtual ~AudioDirectoryInfo() {} + public: using self = AudioDirectoryInfo; using Ptr = std::shared_ptr; - static Ptr Create(const std::string&path); + static Ptr Create( + const std::filesystem::path &path, + const std::filesystem::path &indexPath = ""); virtual std::vector GetFiles() = 0; - virtual ThumbnailTemporaryFile GetThumbnail(const std::string&fileNameOnly, int32_t width, int32_t height) = 0; + virtual ThumbnailTemporaryFile GetThumbnail(const std::string &fileNameOnly, int32_t width, int32_t height) = 0; - virtual std::string GetNextAudioFile(const std::string&fileNameOnly) = 0; - virtual std::string GetPreviousAudioFile(const std::string&fileNameOnly) = 0; + virtual std::string GetNextAudioFile(const std::string &fileNameOnly) = 0; + virtual std::string GetPreviousAudioFile(const std::string &fileNameOnly) = 0; + virtual void MoveAudioFile( + const std::string &directory, + int32_t fromPosition, + int32_t toPosition) = 0; virtual ThumbnailTemporaryFile DefaultThumbnailTemporaryFile() = 0; - static void SetTemporaryDirectory(const std::filesystem::path&path); - static void SetResourceDirectory(const std::filesystem::path&path); - + static void SetTemporaryDirectory(const std::filesystem::path &path); + static void SetResourceDirectory(const std::filesystem::path &path); virtual size_t TestGetNumberOfThumbnails() = 0; // test use only. - virtual void TestSetIndexPath(const std::filesystem::path&path) = 0; + virtual void TestSetIndexPath(const std::filesystem::path &path) = 0; - protected: - std::filesystem::path GetTemporaryDirectory() const; - std::filesystem::path GetResourceDirectory() const; + public: + static std::filesystem::path GetTemporaryDirectory(); + static std::filesystem::path GetResourceDirectory(); private: static std::filesystem::path temporaryDirectory; - static std::filesystem::path resourceDirectory;; - + static std::filesystem::path resourceDirectory; + ; }; - } \ No newline at end of file + + + + std::filesystem::path IndexPathToShadowIndexPath(const std::filesystem::path &audioRootDirectory, const std::filesystem::path &path); + // recover the original path fromthe shadow index path. + std::filesystem::path ShadowIndexPathToIndexPath(const std::filesystem::path &audioRootDirectory, const std::filesystem::path &path); + std::filesystem::path GetShadowIndexDirectory(const std::filesystem::path &audioRootDirectory, const std::filesystem::path &path); + +} \ No newline at end of file diff --git a/src/AudioFilesDb.cpp b/src/AudioFilesDb.cpp index a9915a3..bdc263c 100644 --- a/src/AudioFilesDb.cpp +++ b/src/AudioFilesDb.cpp @@ -78,7 +78,6 @@ namespace pipedal::impl } } lockEntry->mutex.lock(); // we now have exclusive access to the database. - } ~DatabaseLock() @@ -93,9 +92,7 @@ namespace pipedal::impl // Remove the entry from the map if no other locks are held. databaseLocks.erase(indexFile); } - } - } }; } @@ -178,6 +175,8 @@ void AudioFilesDb::CreateDb(const std::filesystem::path &dbPathName) "title TEXT NOT NULL," "track NUMBER NOT NULL," "album TEXT NOT NULL, " + "artist TEXT NOT NULL, " + "albumArtist TEXT NOT NULL, " "duration REAL NOT NULL DEFAULT 0.0," "thumbnailType INTEGER NOT NULL DEFAULT 0," @@ -292,7 +291,7 @@ std::vector AudioFilesDb::QueryTracks() SQLite::Statement query( *db, "SELECT idFile, fileName, " - "lastModified, title, track, album,duration, " + "lastModified, title, track, album, artist,albumArtist,duration, " "thumbnailType, position, " "thumbnailFile, thumbnailLastModified " "FROM files "); @@ -306,11 +305,13 @@ std::vector AudioFilesDb::QueryTracks() row.title(query.getColumn(3).getText()); row.track(query.getColumn(4).getInt()); row.album(query.getColumn(5).getText()); - row.duration(query.getColumn(6).getDouble()); - row.thumbnailType((ThumbnailType)query.getColumn(7).getInt()); - row.position(query.getColumn(8).getInt()); - row.thumbnailFile(query.getColumn(9).getText()); - row.thumbnailLastModified(query.getColumn(10).getInt64()); + row.artist(query.getColumn(6).getText()); + row.albumArtist(query.getColumn(7).getText()); + row.duration(query.getColumn(8).getDouble()); + row.thumbnailType((ThumbnailType)query.getColumn(9).getInt()); + row.position(query.getColumn(10).getInt()); + row.thumbnailFile(query.getColumn(11).getText()); + row.thumbnailLastModified(query.getColumn(12).getInt64()); result.push_back(std::move(row)); } return result; @@ -326,9 +327,9 @@ void AudioFilesDb::WriteFile(DbFileInfo *dbFile) *db, "INSERT INTO files (" "fileName, lastModified," - "title,track,album, " + "title,track,album,artist, albumArtist, " "duration, thumbnailType,thumbnailFile, thumbnailLastModified, position " - ") VALUES (?, ?, ?, ?, ?,?,?,?,?,?)"); + ") VALUES (?, ?, ?, ?, ?,?,?,?,?,?,?,?)"); } insertFileQuery->tryReset(); insertFileQuery->bind(1, dbFile->fileName()); @@ -336,11 +337,13 @@ void AudioFilesDb::WriteFile(DbFileInfo *dbFile) insertFileQuery->bind(3, dbFile->title()); insertFileQuery->bind(4, dbFile->track()); insertFileQuery->bind(5, dbFile->album()); - insertFileQuery->bind(6, dbFile->duration()); - insertFileQuery->bind(7, (int32_t)dbFile->thumbnailType()); - insertFileQuery->bind(8, dbFile->thumbnailFile()); - insertFileQuery->bind(9, dbFile->thumbnailLastModified()); - insertFileQuery->bind(10, dbFile->position()); + insertFileQuery->bind(6, dbFile->artist()); + insertFileQuery->bind(7, dbFile->albumArtist()); + insertFileQuery->bind(8, dbFile->duration()); + insertFileQuery->bind(9, (int32_t)dbFile->thumbnailType()); + insertFileQuery->bind(10, dbFile->thumbnailFile()); + insertFileQuery->bind(11, dbFile->thumbnailLastModified()); + insertFileQuery->bind(12, dbFile->position()); insertFileQuery->exec(); dbFile->idFile(db->getLastInsertRowid()); } @@ -352,7 +355,7 @@ void AudioFilesDb::WriteFile(DbFileInfo *dbFile) *db, "UPDATE files SET " "fileName = ?, lastModified = ?, " - "title = ?, track = ?, album = ?, " + "title = ?, track = ?, album = ?, artist = ? , albumArtist = ?, " "duration = ?, thumbnailType = ?, " "thumbnailFile = ?, thumbnailLastModified = ?, position = ? " " WHERE idFile = ?"); @@ -363,13 +366,15 @@ void AudioFilesDb::WriteFile(DbFileInfo *dbFile) updateFileQuery->bind(3, dbFile->title()); updateFileQuery->bind(4, dbFile->track()); updateFileQuery->bind(5, dbFile->album()); - updateFileQuery->bind(6, dbFile->duration()); - updateFileQuery->bind(7, (int32_t)dbFile->thumbnailType()); - updateFileQuery->bind(8, dbFile->thumbnailFile()); - updateFileQuery->bind(9, dbFile->thumbnailLastModified()); - updateFileQuery->bind(10, dbFile->position()); + updateFileQuery->bind(6, dbFile->artist()); + updateFileQuery->bind(7, dbFile->albumArtist()); + updateFileQuery->bind(8, dbFile->duration()); + updateFileQuery->bind(9, (int32_t)dbFile->thumbnailType()); + updateFileQuery->bind(10, dbFile->thumbnailFile()); + updateFileQuery->bind(11, dbFile->thumbnailLastModified()); + updateFileQuery->bind(12, dbFile->position()); - updateFileQuery->bind(11, dbFile->idFile()); + updateFileQuery->bind(13, dbFile->idFile()); updateFileQuery->exec(); } @@ -480,3 +485,19 @@ void AudioFilesDb::AddThumbnail( throw std::runtime_error("Failed to add thumbnail: " + std::string(e.what())); } } + +void AudioFilesDb::UpdateFilePosition( + int64_t idFile, + int32_t position) +{ + if (!updateFileQuery) + { + updateFileQuery = std::make_unique( + *db, + "UPDATE files SET position = ? WHERE idFile = ?"); + } + updateFileQuery->tryReset(); + updateFileQuery->bind(1, position); + updateFileQuery->bind(2, idFile); + updateFileQuery->exec(); +} diff --git a/src/AudioFilesDb.hpp b/src/AudioFilesDb.hpp index 1188e32..1f44ecb 100644 --- a/src/AudioFilesDb.hpp +++ b/src/AudioFilesDb.hpp @@ -106,6 +106,9 @@ namespace pipedal::impl { int64_t width, int64_t height, const std::vector &thumbnailData); + void UpdateFilePosition( + int64_t idFile, + int32_t position); private: @@ -124,6 +127,7 @@ namespace pipedal::impl { std::unique_ptr deleteFileQuery; std::unique_ptr updateThumbnailInfoQueryByName; std::unique_ptr updateThumbnailInfoQueryById; + std::unique_ptr updatePositionQuery; std::filesystem::path path; }; } diff --git a/src/PiPedalModel.cpp b/src/PiPedalModel.cpp index 86ae020..2c13596 100644 --- a/src/PiPedalModel.cpp +++ b/src/PiPedalModel.cpp @@ -44,6 +44,7 @@ #include "DBusLog.hpp" #include "AvahiService.hpp" #include "DummyAudioDriver.hpp" +#include "AudioFiles.hpp" #ifndef NO_MLOCK #include @@ -2921,4 +2922,17 @@ void PiPedalModel::OnAlsaDriverTerminatedAbnormally() { bool PiPedalModel::IsInUploadsDirectory(const std::string &path) { return storage.IsInUploadsDirectory(path); +} + +void PiPedalModel::MoveAudioFile( + const std::string &directory, + int32_t fromPosition, + int32_t toPosition +) { + if (directory.empty()) + { + throw std::runtime_error("Directory is empty."); + } + AudioDirectoryInfo::Ptr dir = AudioDirectoryInfo::Create(directory); + dir->MoveAudioFile(directory, fromPosition, toPosition); } \ No newline at end of file diff --git a/src/PiPedalModel.hpp b/src/PiPedalModel.hpp index aa551cd..a7de722 100644 --- a/src/PiPedalModel.hpp +++ b/src/PiPedalModel.hpp @@ -462,6 +462,11 @@ namespace pipedal uint64_t CreateNewPreset(); bool LoadCurrentPedalboard(); + + void MoveAudioFile( + const std::string & path, + int32_t from, + int32_t to); }; } // namespace pipedal. \ No newline at end of file diff --git a/src/PiPedalSocket.cpp b/src/PiPedalSocket.cpp index a5efbe4..3f85297 100644 --- a/src/PiPedalSocket.cpp +++ b/src/PiPedalSocket.cpp @@ -60,7 +60,6 @@ JSON_MAP_REFERENCE(PathPatchPropertyChangedBody, propertyUri) JSON_MAP_REFERENCE(PathPatchPropertyChangedBody, atomJson) JSON_MAP_END() - class GetPatchPropertyBody { public: @@ -74,6 +73,20 @@ JSON_MAP_REFERENCE(GetPatchPropertyBody, instanceId) JSON_MAP_REFERENCE(GetPatchPropertyBody, propertyUri) JSON_MAP_END() +class MoveAudioFileArgs +{ +public: + std::string path_; + int32_t from_; + int32_t to_; + DECLARE_JSON_MAP(MoveAudioFileArgs); +}; +JSON_MAP_BEGIN(MoveAudioFileArgs) +JSON_MAP_REFERENCE(MoveAudioFileArgs, path) +JSON_MAP_REFERENCE(MoveAudioFileArgs, from) +JSON_MAP_REFERENCE(MoveAudioFileArgs, to) +JSON_MAP_END() + class CreateNewSampleDirectoryArgs { public: @@ -111,14 +124,11 @@ public: DECLARE_JSON_MAP(GetFilePropertyDirectoryTreeArgs); }; - JSON_MAP_BEGIN(GetFilePropertyDirectoryTreeArgs) JSON_MAP_REFERENCE(GetFilePropertyDirectoryTreeArgs, fileProperty) JSON_MAP_REFERENCE(GetFilePropertyDirectoryTreeArgs, selectedPath) JSON_MAP_END() - - class Lv2StateChangedBody { public: @@ -413,7 +423,8 @@ JSON_MAP_REFERENCE(SetSnapshotsBody, snapshots) JSON_MAP_REFERENCE(SetSnapshotsBody, selectedSnapshot) JSON_MAP_END() -class SnapshotModifiedBody { +class SnapshotModifiedBody +{ public: int64_t snapshotIndex_; bool modified_; @@ -582,7 +593,6 @@ public: { FinalCleanup(); } - } bool finalCleanup = false; @@ -1100,7 +1110,7 @@ public: else if (message == "getHasWifi") { bool result = model.GetHasWifi(); - this->Reply(replyTo, "getHasWifi",result); + this->Reply(replyTo, "getHasWifi", result); } else if (message == "updateNow") { @@ -1473,13 +1483,8 @@ public: SetPatchPropertyBody body; pReader->read(&body); model.SendSetPatchProperty(clientId, body.instanceId_, body.propertyUri_, body.value_, [this, replyTo]() - { - this->JsonReply(replyTo, "setPatchProperty", "true"); - }, - [this, replyTo](const std::string &error) - { - this->SendError(replyTo, error.c_str()); - }); + { this->JsonReply(replyTo, "setPatchProperty", "true"); }, [this, replyTo](const std::string &error) + { this->SendError(replyTo, error.c_str()); }); } else if (message == "getPatchProperty") @@ -1644,13 +1649,31 @@ public: { GetFilePropertyDirectoryTreeArgs args; pReader->read(&args); - FilePropertyDirectoryTree::ptr result = + FilePropertyDirectoryTree::ptr result = + model.GetFilePropertydirectoryTree( + args.fileProperty_, + args.selectedPath_); + this->Reply(replyTo, "GetFilePropertydirectoryTree", result); + } + else if (message == "getFilePropertyDirectoryTree") + { + GetFilePropertyDirectoryTreeArgs args; + pReader->read(&args); + FilePropertyDirectoryTree::ptr result = model.GetFilePropertydirectoryTree( args.fileProperty_, args.selectedPath_); this->Reply(replyTo, "GetFilePropertydirectoryTree", result); } + else if (message == "moveAudioFile") + { + MoveAudioFileArgs args; + pReader->read(&args); + this->model.MoveAudioFile(args.path_, args.from_, args.to_); + bool result = true; + this->Reply(replyTo,"moveAudioFile", result); + } else if (message == "setOnboarding") { bool value; @@ -1659,7 +1682,7 @@ public: } else if (message == "getWifiRegulatoryDomains") { - auto regulatoryDomains = this->model.GetWifiRegulatoryDomains(); + auto regulatoryDomains = this->model.GetWifiRegulatoryDomains(); this->Reply(replyTo, "getWifiRegulatoryDomains", regulatoryDomains); } else @@ -1670,7 +1693,9 @@ public: } protected: - virtual void onSocketClosed() override { + virtual void + onSocketClosed() override + { SocketHandler::OnSocketClosed(); this->Close(); } @@ -1754,10 +1779,10 @@ private: Send("onLv2PluginsChanging", true); Flush(); } - virtual void OnHasWifiChanged(bool hasWifi){ + virtual void OnHasWifiChanged(bool hasWifi) + { Send("onHasWifiChanged", hasWifi); Flush(); - } virtual void OnNetworkChanging(bool hotspotConnected) override @@ -1809,7 +1834,7 @@ private: Send("onChannelSelectionChanged", body); } - virtual void OnSnapshotModified(int64_t snapshotIndex, bool modified) + virtual void OnSnapshotModified(int64_t snapshotIndex, bool modified) { SnapshotModifiedBody body; body.snapshotIndex_ = snapshotIndex; @@ -2124,7 +2149,6 @@ private: std::atomic PiPedalSocketHandler::nextClientId = 0; - class PiPedalSocketFactory : public ISocketFactory { private: @@ -2152,7 +2176,6 @@ public: } }; - std::shared_ptr pipedal::MakePiPedalSocketFactory(PiPedalModel &model) { return std::make_shared(model); diff --git a/src/Storage.cpp b/src/Storage.cpp index ae7f0e6..1e959fe 100644 --- a/src/Storage.cpp +++ b/src/Storage.cpp @@ -1689,6 +1689,7 @@ static void AddFilesToResult( } static void AddTracksToResult( + const fs::path &audioRootDirectory, FileRequestResult &result, const ModFileTypes::ModDirectory *modDirectoryInfo, // yyx const UiFileProperty &fileProperty, @@ -1716,7 +1717,22 @@ static void AddTracksToResult( resultFiles.push_back(FileEntry{path, name, true, fs::is_symlink(path)}); } } - auto audioFiles = AudioDirectoryInfo::Create(rootPath); + auto collator = Locale::GetInstance()->GetCollator(); + std::sort( + resultFiles.begin(), + resultFiles.end(), + [collator](const FileEntry &l, const FileEntry &r) + { + if (l.isDirectory_ != r.isDirectory_) + { + return l.isDirectory_ > r.isDirectory_; + } + return collator->Compare(l.displayName_ ,r.displayName_) < 0; + }); + // Add audio files. + auto audioFiles = AudioDirectoryInfo::Create(rootPath, + GetShadowIndexDirectory(audioRootDirectory,rootPath) + ); for (const auto &audioFile : audioFiles->GetFiles()) { fs::path audioFilePath = rootPath / audioFile.fileName(); @@ -1821,7 +1837,7 @@ FileRequestResult Storage::GetModFileList2(const std::string &relativePath, cons if (IsInAudioTracksDirectory(relativePath)) { - AddTracksToResult(result, rootModDirectory, fileProperty, relativePath); + AddTracksToResult(this->GetPluginUploadDirectory(),result, rootModDirectory, fileProperty, relativePath); } else { @@ -1921,7 +1937,7 @@ FileRequestResult Storage::GetFileList2(const std::string &relativePath_, const if (IsInAudioTracksDirectory(absolutePath)) { - AddTracksToResult(result, pModDirectory, fileProperty, absolutePath); + AddTracksToResult(GetPluginUploadDirectory(), result, pModDirectory, fileProperty, absolutePath); } else { diff --git a/src/Uri.hpp b/src/Uri.hpp index b07d9cf..52951f8 100644 --- a/src/Uri.hpp +++ b/src/Uri.hpp @@ -63,7 +63,7 @@ public: { set(""); } - const std::string& str() { + const std::string& str() const { return text; } uri(const char*text) diff --git a/src/WebServerConfig.cpp b/src/WebServerConfig.cpp index 69f967f..140664d 100644 --- a/src/WebServerConfig.cpp +++ b/src/WebServerConfig.cpp @@ -37,6 +37,7 @@ #include "MimeTypes.hpp" #include "AudioFileMetadataReader.hpp" #include "AudioFiles.hpp" +#include "util.hpp" #define OLD_PRESET_EXTENSION ".piPreset" #define PRESET_EXTENSION ".piPreset" @@ -50,6 +51,10 @@ static const std::string PLUGIN_PRESETS_MIME_TYPE = "application/vnd.pipedal.plu static const std::string PRESET_MIME_TYPE = "application/vnd.pipedal.preset"; static const std::string BANK_MIME_TYPE = "application/vnd.pipedal.bank"; +static const std::string CACHE_CONTROL_INDEFINITELY = "max-age=31536000,public,immutable"; // 1 year +static const std::string CACHE_CONTROL_SHORT = "max-age=300,public"; // 5 minutes + + using namespace pipedal; using namespace boost::system; namespace fs = std::filesystem; @@ -137,6 +142,7 @@ private: std::vector extensions; }; + class DownloadIntercept : public RequestHandler { PiPedalModel *model; @@ -396,6 +402,20 @@ public: } } + static void setLastModifiedFromFile(HttpResponse &res, const std::filesystem::path &path) + { + auto lastModified = std::filesystem::last_write_time(path); + res.set(HttpField::LastModified, HtmlHelper::timeToHttpDate(lastModified)); + } + AudioDirectoryInfo::Ptr CreateDirectoryInfo(const fs::path &path) { + return AudioDirectoryInfo::Create(path, + GetShadowIndexDirectory( + this->model->GetPluginUploadDirectory(), + path)); + } + + + virtual void get_response( const uri &request_uri, HttpRequest &req, @@ -493,8 +513,7 @@ public: { throw PiPedalException("File not found."); } - AudioDirectoryInfo::Ptr audioDirectory = AudioDirectoryInfo::Create(path.parent_path()); - + AudioDirectoryInfo::Ptr audioDirectory = CreateDirectoryInfo(path.parent_path()); auto files = audioDirectory->GetFiles(); for (const auto &file : files) { @@ -516,7 +535,26 @@ public: try { fs::path path = request_uri.query("path"); + if (path.empty()) + { + // path for folder thumbnails. + path = request_uri.query("ffile"); + if (!fs::exists(path) || !this->model->IsInUploadsDirectory(path) || HasDotDot(path)) + { + throw PiPedalException("File not found."); + } + std::shared_ptr thumbnail = std::make_shared(); + thumbnail->SetNonDeletedPath(path); + res.set(HttpField::content_type, MimeTypes::instance().MimeTypeFromExtension(path.extension())); + res.set(HttpField::cache_control, CACHE_CONTROL_INDEFINITELY); // URL is cache-busted, and will change if the file ismodified. + setLastModifiedFromFile(res,path); + res.set(HttpField::content_length, std::to_string(fs::file_size(path))); + res.setBodyFile(thumbnail); + return; + + } + if (!fs::exists(path) || !this->model->IsInUploadsDirectory(path) || HasDotDot(path)) { throw PiPedalException("File not found."); @@ -525,8 +563,10 @@ public: int32_t width = ConvertThumbnailSize(request_uri.query("w")); int32_t height = ConvertThumbnailSize(request_uri.query("h")); - AudioDirectoryInfo::Ptr audioDirectory = AudioDirectoryInfo::Create(path.parent_path()); + AudioDirectoryInfo::Ptr audioDirectory = CreateDirectoryInfo( + path.parent_path()); audioDirectory->GetFiles(); // ensure that the .index file is up to date. + ThumbnailTemporaryFile thumbnail; try { thumbnail = audioDirectory->GetThumbnail(path.filename(), width, height); @@ -535,7 +575,8 @@ public: } res.set(HttpField::content_type, thumbnail.GetMimeType()); - res.set(HttpField::cache_control, "max-age=300"); + res.set(HttpField::cache_control, CACHE_CONTROL_INDEFINITELY); // URL is cache-busted with time-stamp. + setLastModifiedFromFile(res, path); res.set(HttpField::content_length, std::to_string(fs::file_size(thumbnail.Path()))); std::filesystem::path t = thumbnail.Path(); @@ -544,9 +585,7 @@ public: } catch (const std::exception &e) { - Lv2Log::error("Error getting thumbnail: %s", e.what()); - res.set(HttpField::location, "/img/missing_thumbnail.jpg"); - // response = 307 + Lv2Log::error("Error getting thumbnail: %s - (%s)", request_uri.str().c_str(), e.what()); throw e; } } @@ -563,7 +602,7 @@ public: throw PiPedalException("File not found."); } auto directoryPath = path.parent_path(); - auto directoryInfo = AudioDirectoryInfo::Create(directoryPath); + auto directoryInfo = CreateDirectoryInfo(directoryPath); std::string result = directoryInfo->GetNextAudioFile(path.filename()); if (!result.empty()) { @@ -595,7 +634,7 @@ public: throw PiPedalException("File not found."); } auto directoryPath = path.parent_path(); - auto directoryInfo = AudioDirectoryInfo::Create(directoryPath); + auto directoryInfo = CreateDirectoryInfo(directoryPath); std::string result = directoryInfo->GetPreviousAudioFile(path.filename()); if (!result.empty()) { diff --git a/todo.txt b/todo.txt index 2e74d1b..8c66338 100644 --- a/todo.txt +++ b/todo.txt @@ -1,6 +1,5 @@ -Sort order. -Do we have a better plugin category than "Utility" -.index files. +Serialize sort order. + - pipewire aux in? libsqlite3-dev install diff --git a/vite/src/pipedal/AudioFileMetadata.tsx b/vite/src/pipedal/AudioFileMetadata.tsx index b79e629..170db7e 100644 --- a/vite/src/pipedal/AudioFileMetadata.tsx +++ b/vite/src/pipedal/AudioFileMetadata.tsx @@ -21,6 +21,7 @@ * SOFTWARE. */ +import { pathConcat, pathParentDirectory } from "./FileUtils"; import { PiPedalModel } from "./PiPedalModel"; export class ThumbnailType { @@ -39,6 +40,8 @@ export default class AudioFileMetadata { this.title = o.title; this.track = o.track; this.album = o.album; + this.artist = o.artist; + this.albumArtist = o.albumArtist; this.duration = o.duration; this.thumbnailType = o.thumbnailType; this.thumbnailFile = o.thumbnailFile; @@ -47,16 +50,20 @@ export default class AudioFileMetadata { return this; } fileName: string = ""; - lastModified: number = 0; + lastModified: number = 0; title: string = ""; track: number = 0; album: string = ""; + artist: string = ""; + albumArtist: string = ""; duration: number = 0; thumbnailType: ThumbnailType = new ThumbnailType(); thumbnailFile = ""; thumbnailLastModified = 0; } + + export function getAlbumArtUri(model: PiPedalModel, metadata: AudioFileMetadata | undefined, path: string): string { let coverArtUri: string; if (!metadata) { @@ -65,10 +72,21 @@ export function getAlbumArtUri(model: PiPedalModel, metadata: AudioFileMetadata if (metadata.thumbnailType === ThumbnailType.None) { return "/img/missing_thumbnail.jpg"; + } else if (metadata.thumbnailType === ThumbnailType.Folder) { + // Use the thumbnailFile to get the cover art. + let thumbnailPath = pathConcat(pathParentDirectory(path) ,metadata.thumbnailFile); + + coverArtUri = model.varServerUrl + "Thumbnail" + + "?ffile=" + encodeURIComponent(thumbnailPath) + + "&t=" + metadata.thumbnailLastModified + + "&w=240&h=240" + ; + return coverArtUri; } else { + // for embeed and unknown coverArtUri = model.varServerUrl + "Thumbnail" + "?path=" + encodeURIComponent(path) - + "&t=" + metadata.thumbnailLastModified + + "&t=" + metadata.lastModified + "&w=240&h=240" ; return coverArtUri; diff --git a/vite/src/pipedal/DraggableButtonBase.tsx b/vite/src/pipedal/DraggableButtonBase.tsx index 8596c9b..e12d066 100644 --- a/vite/src/pipedal/DraggableButtonBase.tsx +++ b/vite/src/pipedal/DraggableButtonBase.tsx @@ -35,6 +35,17 @@ export interface DraggableButtonBaseProps extends ButtonBaseProps { interface Point { x: number; y: number; +}; + +function isValidPointer(e: React.PointerEvent): boolean { + if (e.pointerType === "mouse") { + return e.button === 0; + } else if (e.pointerType === "pen") { + return true; + } else if (e.pointerType === "touch") { + return true; + } + return false; } function screenToClient(element: HTMLElement, point: Point): Point { @@ -81,6 +92,12 @@ export default function DraggableButtonBase(props: DraggableButtonBaseProps) { { + if (!isValidPointer(e)) { + return; + } + if (pointerId !== null) { + return; //tracking another pointer already. + } e.currentTarget.setPointerCapture(e.pointerId); setPointerId(e.pointerId); setPointerDownPoint(screenToClient(e.currentTarget,{ x: e.screenX, y: e.screenY })); diff --git a/vite/src/pipedal/FilePropertyDialog.tsx b/vite/src/pipedal/FilePropertyDialog.tsx index 74ea421..ac0b1fa 100644 --- a/vite/src/pipedal/FilePropertyDialog.tsx +++ b/vite/src/pipedal/FilePropertyDialog.tsx @@ -49,7 +49,7 @@ import Toolbar from '@mui/material/Toolbar'; import WithStyles from './WithStyles'; import { withStyles } from "tss-react/mui"; import CircularProgress from '@mui/material/CircularProgress'; - +import { pathConcat,pathParentDirectory,pathFileName,pathFileNameOnly, pathExtension } from './FileUtils'; './FileUtils'; import ResizeResponsiveComponent from './ResizeResponsiveComponent'; @@ -139,6 +139,7 @@ export interface FilePropertyDialogState { fileResult: FileRequestResult; navDirectory: string; windowWidth: number; + windowHeight: number; currentDirectory: string; isProtectedDirectory: boolean; columns: number; @@ -158,60 +159,6 @@ class DragState { from: number = 0; to: number = 0; }; -function pathExtension(path: string) { - let dotPos = path.lastIndexOf('.'); - if (dotPos === -1) return ""; - - let slashPos = path.lastIndexOf('/'); - if (slashPos !== -1) { - if (dotPos <= slashPos + 1) return ""; - } - return path.substring(dotPos); // include the '.'. - -} -function pathParentDirectory(path: string) { - let npos = path.lastIndexOf('/'); - if (npos === -1) return ""; - return path.substring(0, npos); -} -function pathConcat(left: string, right: string) { - if (left === "") return right; - if (right === "") return left; - if (left.endsWith('/')) { - left = left.substring(0, left.length - 1); - } - if (right.startsWith("/")) { - right = right.substring(1); - } - return left + "/" + right; -} -export function pathFileNameOnly(path: string): string { - if (path === "..") return path; - let slashPos = path.lastIndexOf('/'); - if (slashPos < 0) { - slashPos = 0; - } else { - ++slashPos; - } - let extPos = path.lastIndexOf('.'); - if (extPos < 0 || extPos < slashPos) { - extPos = path.length; - } - - return path.substring(slashPos, extPos); -} - -export function pathFileName(path: string): string { - if (path === "..") return path; - let slashPos = path.lastIndexOf('/'); - if (slashPos < 0) { - slashPos = 0; - } else { - ++slashPos; - } - return path.substring(slashPos); -} - export default withStyles( class FilePropertyDialog extends ResizeResponsiveComponent { @@ -242,6 +189,7 @@ export default withStyles( selectedFile: selectedFile, selectedFileProtected: true, windowWidth: this.windowSize.width, + windowHeight: this.windowSize.height, selectedFileIsDirectory: false, navDirectory: this.getNavDirectoryFromFile(selectedFile, props.fileProperty), currentDirectory: "", @@ -370,7 +318,7 @@ export default withStyles( element.style.zIndex = "1000"; element.style.top = "5px"; element.style.left = "5px"; - element.style.background = isDarkMode() ? "#555": "#EEF" // xxx: dark mode. + element.style.background = isDarkMode() ? "#555" : "#EEF" // xxx: dark mode. if (this.lastDivRef) { this.longPressStartPoint = screenToClient(this.lastDivRef, { x: e.screenX, y: e.screenY }); } @@ -450,15 +398,15 @@ export default withStyles( newFileResult.files.splice(ixTo, 0, newFileResult.files[ixFrom]); newFileResult.files.splice(ixFrom + 1, 1); } else { - // move down. - newFileResult.files.splice(ixTo+1,0,newFileResult.files[ixFrom]); + // move down. + newFileResult.files.splice(ixTo + 1, 0, newFileResult.files[ixFrom]); newFileResult.files.splice(ixFrom, 1); } - this.setState({ fileResult: newFileResult ,dragState: null}); + this.setState({ fileResult: newFileResult, dragState: null }); // Now send the reorder request to the server. - this.model.reorderAudioFiles( + this.model.moveAudioFile( this.state.currentDirectory, from, to); } handleLongPressEnd(e: React.PointerEvent) { @@ -499,7 +447,8 @@ export default withStyles( this.setState({ fullScreen: this.getFullScreen(), - windowWidth: width + windowWidth: width, + windowHeight: height }) if (this.lastDivRef !== null) { this.onMeasureRef(this.lastDivRef); @@ -679,9 +628,32 @@ export default withStyles( this.requestFiles(relativeDirectory); this.setState({ navDirectory: relativeDirectory }); } + getCompactTrackTitle(fileEntry: FileEntry): string { + let metadata = fileEntry.metadata; + if (!metadata) { + return "#error"; + } + let title = this.getTrackTitle(fileEntry); + if (metadata.album !== "") { + title += " (" + metadata.album + ")"; + } + return title; + } + getAlbumTitle(fileEntry: FileEntry): string { + let artist = fileEntry.metadata?.artist || ""; + if (artist == "" ) { + artist = fileEntry.metadata?.albumArtist || ""; + } + let album = fileEntry.metadata?.album || ""; + let joiner = (artist !== "" && album !== "") ? " - " : ""; + return album + joiner + artist; + } getTrackTitle(fileEntry: FileEntry): string { if (!fileEntry.metadata) { - return fileEntry.displayName; + return pathFileName(fileEntry.pathname); + } + if (fileEntry.metadata.title === "") { + return pathFileName(fileEntry.pathname); } let metadata = fileEntry.metadata; let trackDisplay = ""; @@ -692,7 +664,11 @@ export default withStyles( trackDisplay = metadata.track.toString() + ". "; } } - return trackDisplay + metadata.title; + let result = trackDisplay + metadata.title; + if (result === "") { + result = pathFileName(fileEntry.pathname); + } + return result; } getTrackThumbnail(fileEntry: FileEntry): string { return getAlbumArtUri(this.model, fileEntry.metadata, fileEntry.pathname); @@ -793,19 +769,29 @@ export default withStyles( } return result; } - private getIcon(fileEntry: FileEntry) { + private getIcon(fileEntry: FileEntry, largeIcon: boolean) { + let style = largeIcon + ? { + flex: "0 0 auto", opacity: 0.7, width: 32, height: 32, + marginLeft: 16, marginRight: 24, marginTop: 16, marginBottom: 16, + } + : { flex: "0 0 auto", opacity: 0.7, marginRight: 8, marginLeft: 8 }; + + + + if (fileEntry.pathname === "") { - return (); + return (); } if (fileEntry.isDirectory) { return ( - + ); } if (isAudioFile(fileEntry.pathname)) { - return (); + return (); } - return (); + return (); } render() { @@ -827,6 +813,7 @@ export default withStyles( let canReorder = isTracksDirectory; let needsDivider = canMove || canRename || canReorder; let trackPosition = 0; + let compactVertical = this.state.windowHeight < 700; return this.props.open && ( @@ -1031,7 +1018,7 @@ export default withStyles( } style={{ - width: columnWidth, flex: "0 0 auto", height: value.metadata ? 64 : 48, + width: columnWidth, flex: "0 0 auto", height: (value.metadata && !compactVertical) ? 64 : 48, position: "relative", top: dragOffset + "px", }} @@ -1054,39 +1041,63 @@ export default withStyles( >

{value.metadata ? - ( -
- { e.preventDefault(); }} - src={this.getTrackThumbnail(value)} - style={{ width: 48, height: 48, margin: 8, borderRadius: 4 }} /> + (!compactVertical ? + (
- - {this.getTrackTitle(value)} - - - {value.metadata?.album ?? "#error"} + { e.preventDefault(); }} + src={this.getTrackThumbnail(value)} + style={{ width: 48, height: 48, margin: 8, borderRadius: 4 }} /> +
+ + {this.getTrackTitle(value)} + + + {this.getAlbumTitle(value)} - + +
-
+ ) : ( +
+ { e.preventDefault(); }} + src={this.getTrackThumbnail(value)} + style={{ width: 24, height: 24, margin: 8, borderRadius: 4 }} /> +
+ + {this.getCompactTrackTitle(value)} + +
+
+ ) ) : (
- {this.getIcon(value)} + {this.getIcon(value, this.isTracksDirectory() && !compactVertical)} {displayValue}
)} diff --git a/vite/src/pipedal/FileUtils.tsx b/vite/src/pipedal/FileUtils.tsx new file mode 100644 index 0000000..a1bf1c1 --- /dev/null +++ b/vite/src/pipedal/FileUtils.tsx @@ -0,0 +1,80 @@ +/* + * 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, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * 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. + */ + + +export function pathExtension(path: string) { + let dotPos = path.lastIndexOf('.'); + if (dotPos === -1) return ""; + + let slashPos = path.lastIndexOf('/'); + if (slashPos !== -1) { + if (dotPos <= slashPos + 1) return ""; + } + return path.substring(dotPos); // include the '.'. + +} +export function pathParentDirectory(path: string) { + let npos = path.lastIndexOf('/'); + if (npos === -1) return ""; + return path.substring(0, npos); +} +export function pathConcat(left: string, right: string) { + if (left === "") return right; + if (right === "") return left; + if (left.endsWith('/')) { + left = left.substring(0, left.length - 1); + } + if (right.startsWith("/")) { + right = right.substring(1); + } + return left + "/" + right; +} +export function pathFileNameOnly(path: string): string { + if (path === "..") return path; + let slashPos = path.lastIndexOf('/'); + if (slashPos < 0) { + slashPos = 0; + } else { + ++slashPos; + } + let extPos = path.lastIndexOf('.'); + if (extPos < 0 || extPos < slashPos) { + extPos = path.length; + } + + return path.substring(slashPos, extPos); +} + +export function pathFileName(path: string): string { + if (path === "..") return path; + let slashPos = path.lastIndexOf('/'); + if (slashPos < 0) { + slashPos = 0; + } else { + ++slashPos; + } + return path.substring(slashPos); +} + + + diff --git a/vite/src/pipedal/LoopDialog.tsx b/vite/src/pipedal/LoopDialog.tsx new file mode 100644 index 0000000..a177ac2 --- /dev/null +++ b/vite/src/pipedal/LoopDialog.tsx @@ -0,0 +1,630 @@ +/* + * 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, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * 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, { SyntheticEvent, useEffect } from "react"; +import FormGroup from "@mui/material/FormGroup"; +import FormControlLabel from "@mui/material/FormControlLabel"; +import DialogContent from "@mui/material/DialogContent"; +import TextField, { StandardTextFieldProps } from "@mui/material/TextField"; +import DialogTitle from "@mui/material/DialogTitle"; +import DialogEx from "./DialogEx"; +import Toolbar from "@mui/material/Toolbar"; +import IconButton from "@mui/material/IconButton"; +import ArrowBackIcon from "@mui/icons-material/ArrowBack"; +import Typography from "@mui/material/Typography"; +import Slider, { SliderProps } from "@mui/material/Slider"; +import Checkbox from "@mui/material/Checkbox"; +import TimebaseSelectorDialog from "./TimebaseselectorDialog"; +import Timebase, { TimebaseUnits } from "./Timebase"; + +export interface LoopDialogProps { + isOpen: boolean; + onClose: () => void; + onSetLoop: (start: number, loopEnable: boolean, loopStart: number, loopEnd: number) => void; + start: number; + loopEnable: boolean; + loopStart: number; + loopEnd: number; + duration: number; + fullScreen?: boolean; + timebase: Timebase; + onTimebaseChange: (timebase: Timebase) => void; // Optional callback for timebase changes + sampleRate: number; + +}; + + +interface TimeEditProps extends StandardTextFieldProps { + timebase: Timebase; + sampleRate: number; + value: number; + onValueChange?: (e: React.ChangeEvent, value: number) => void; + onBlur: () => void; + max: number; +}; + + + +function parseTime(timebase: Timebase, sampleRate: number, duration: number, timeString: string): number { + + switch (timebase.units) { + case TimebaseUnits.Samples: + { + const samples = parseInt(timeString, 10); + if (isNaN(samples)) { + return samples; + } + let result = samples / sampleRate; // Convert samples to seconds + if (result < 0) { + result = 0; + } + if (result > duration) { + result = duration; + } + return result; + + } + case TimebaseUnits.Seconds: + { + const parts = timeString.split(':'); + let hours = 0; + let minutes = 0; + let seconds = 0; + + if (parts.length === 1) { + hours = 0; + minutes = 0; + seconds = parseFloat(parts[0]); + if (isNaN(seconds)) { + throw new Error("Invalid seconds format. Use 'mm:ss' or 'mm:ss.mmmm'."); + } + } else if (parts.length === 2) { + hours = 0; + minutes = parseInt(parts[0], 10); + seconds = parseFloat(parts[1]); + if (isNaN(minutes) || isNaN(seconds)) { + throw new Error("Invalid minutes or seconds format. Use 'mm:ss' or 'mm:ss.mmmm'."); + } + } else if (parts.length === 3) { + hours = parseInt(parts[0]); + minutes = parseInt(parts[1], 10); + seconds = parseFloat(parts[2]); + if (isNaN(hours) || isNaN(minutes) || isNaN(seconds)) { + throw new Error("Invalid hours, minutes or seconds format. Use 'hh:mm:ss' or 'mm:ss.mmmm'."); + } + } else { + throw new Error("Invalid time format. Use 'hh:mm:ss' or 'mm:ss.mmmm'."); + } + + let result = hours * 60 * 60 + minutes * 60 + seconds; // Convert total time to seconds + if (result < 0) { + result = 0; + } + if (result > duration) { + result = duration; + } + return result; + + } + case TimebaseUnits.Beats: + { + const parts = timeString.split(':'); + let bar = 0; + let beat = 0; + + + + + if (parts.length === 1) { + bar = 1; + beat = parseFloat(parts[0]); + if (isNaN(beat)) { + throw new Error("Invalid seconds format. Use 'bar:beat' or 'bar:beat.fraction'."); + } + if (beat < 1 || beat >= timebase.timeSignature.numerator+1) { + throw new Error(`Beat number must be between 1 and ${timebase.timeSignature.numerator+1}.`); + } + } else if (parts.length === 2) { + bar = parseInt(parts[0], 10); + if (bar < 1) { + throw new Error("Bar number must be 1 or greater."); + } + beat = parseFloat(parts[1]); + if (isNaN(bar) || isNaN(beat)) { + throw new Error("Invalid bar or beat format. Use 'bar:beat' or 'bar:beat.fraction'."); + } + if (beat < 1 || beat >= timebase.timeSignature.numerator+1) { + throw new Error(`Beat number must be between 1 and ${timebase.timeSignature.numerator+1}.`); + } + } else { + throw new Error("Invalid time format. Use 'hh:mm:ss' or 'mm:ss.mmmm'."); + } + const actualBeat = (bar-1) * timebase.timeSignature.numerator + (beat-1); // Convert bar and beat to actual beat number + let result = actualBeat *60/ timebase.tempo; // Convert beats to seconds based on tempo + if (result < 0) { + result = 0; + } + if (result > duration) { + result = duration; + } + return result; + } + } +} + +function formatTime(timebase: Timebase, sampleRate: number, seconds: number): string { + switch (timebase.units) { + case TimebaseUnits.Samples: + { + return Math.round(seconds * sampleRate).toString(); + } + break; + case TimebaseUnits.Seconds: + { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + const mmillis = Math.floor((seconds % 1) * 10000); + if (hours == 0) { + return `${minutes}:${secs.toString().padStart(2, '0')}.${mmillis.toString().padStart(4, '0')}`; + } else { + return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}.${mmillis.toString().padStart(4, '0')}`; + } + break; + } + case TimebaseUnits.Beats: + { + let beats = timebase.tempo / 60.0 * seconds; + const bars = Math.floor(beats / timebase.timeSignature.numerator); + const beat = (beats - bars * timebase.timeSignature.numerator); + return `${bars + 1}:${(beat + 1).toFixed(4) }`; + } + break; + default: + throw new Error("Unsupported timebase units"); + } +} + + + + +interface SliderWithPreviewProps extends SliderProps { + style?: React.CSSProperties; + value: number | number[]; + onChange: (event: Event | SyntheticEvent, value: number | number[], thumb?: number) => void; + onPreview?: (event: Event | SyntheticEvent, value: number | number[], thumb?: number) => void; + onCommitPreviewValue?: (event: Event | SyntheticEvent, value: number | number[], thumb?: number) => void; +} +interface PreviewArguments { + newValue: number | number[]; + thumb?: number; +}; + +function SliderWithPreview(props: SliderWithPreviewProps) { + const { value, onChange, onPreview, onCommitPreviewValue, style, ...extra } = props; + const [previewArguments, setPreviewArguments] = + React.useState(null); + + const [pointerDown, setPointerDown] = React.useState(false); + + + // code to handle a bug in crhome relating to mouseup events, that causes zero values to not be set. + + const handleMouseUp = (e: MouseEvent) => { + if (pointerDown && e.button === 0) { // Left mouse button + setPointerDown(false); + if (previewArguments) { + if (onCommitPreviewValue) { + onCommitPreviewValue(e as Event, previewArguments.newValue, previewArguments.thumb); + } + setPreviewArguments(null); + } + } + }; + + useEffect(() => { + document.addEventListener('mouseup', handleMouseUp); + + // Cleanup: Remove the event listener when the component unmounts + return () => { + document.removeEventListener('mouseup', handleMouseUp); + }; + }, []); // Re-run effect when tempValue changes to ensure latest value is committed + + + return ( + { + if (pointerDown) { + if (props.onPreview) { + props.onPreview(event, newValue, thumb); + } + setPreviewArguments({ newValue, thumb }); + } else { + if (props.onChange) { + props.onChange(event, newValue, thumb); + } + return; + } + }} + + onChangeCommitted={(event, newValue) => { + if (pointerDown) { + if (props.onPreview) { + props.onPreview(event as SyntheticEvent, newValue, 0); + } + setPreviewArguments({ newValue: newValue, thumb: 0 }); + } else { + if (props.onChange) { + props.onChange(event, newValue, 0); + } + return; + } + }} + onMouseDown={(e) => { + if (e.button == 0) { + setPointerDown(true); + } + }} + onMouseUp={(e) => { + if (e.button == 0) { // Left mouse button + setPointerDown(false); + if (previewArguments) { + if (onCommitPreviewValue) { + onCommitPreviewValue(e, previewArguments.newValue, previewArguments.thumb); + } + setPreviewArguments(null); + } + } + + }} + onMouseMove={(e) => { + if (pointerDown && previewArguments) { + if (props.onPreview) { + if (e.clientX < 0) // bug in crhrome + { + props.onPreview(e, previewArguments.newValue, previewArguments.thumb); + + } + } + } + } + } + /> + ); +} + + +function TimeEdit(props: TimeEditProps) { + let { value, onValueChange, onBlur, timebase,sampleRate,max, ...extra } = props; + const [text, setText] = React.useState(formatTime(timebase, props.sampleRate, props.value)); + const [error, setError] = React.useState(false); + const [editValue, setEditValue] = React.useState(props.value); + const [focus, setFocus] = React.useState(false); + // slice props. + + React.useEffect(() => { + if (!focus ) { + setText(formatTime(timebase, sampleRate, props.value)); + } + }, + [props.value]); + + React.useEffect(() => { + setText(formatTime(props.timebase, sampleRate, props.value)); + }, [props.timebase, sampleRate]); + + + + return ( + { + console.log("onBlur", text, props.value, editValue, + formatTime(props.timebase, sampleRate, props.value)); + console.log("onBlur", props.value); + setText(formatTime(props.timebase, sampleRate, props.value)); + setFocus(false); + }} + onChange={(e) => { + try { + setText(e.target.value); + let val = parseTime(props.timebase, sampleRate, max, e.target.value); + if (!isNaN(val)) { + if (val > max) { + val = max; + } + setEditValue(val); + if (props.onValueChange) { + props.onValueChange(e, val); + } + setError(false); + } else { + setError(true); + } + } catch (err) { + setError(true); + } + }} + onFocus={(e) => { + setFocus(true); + setText(formatTime(props.timebase, sampleRate, props.value)); + setError(false); + e.target.select(); + } + } + inputProps={{ + style: { textAlign: "center" }, + autoComplete: "off", + spellCheck: "false", + autoCorrect: "off", + autoCapitalize: "off", + }} + /> + ); +} + + + +export default function LoopDialog(props: LoopDialogProps) { + const [previewStartValue, setPreviewStartValue] = React.useState(null); + const [previewLoopStart, setPreviewLoopStart] = React.useState(null); + const [previewLoopEnd, setPreviewLoopEnd] = React.useState(null); + + + + return ( + { + }} + + sx={{ + "& .MuiDialog-container": { + "& .MuiPaper-root": { + width: "100%", + maxWidth: "500px", // Set your width here + }, + }, + }} + > + + + { props.onClose(); }} + > + + + + Loop + +
+ + { + props.onTimebaseChange(timebase); // Handle timebase change if needed + }} + timebase={props.timebase} // Pass the current timebase if needed + /> + + + +
+ + Start + + { + let val = Array.isArray(v) ? v[0] : v + + if (props.onSetLoop) { + props.onSetLoop(val, props.loopEnable, props.loopStart, props.loopEnd); + } + }} + onPreview={(e, v, thumb) => { + let val = Array.isArray(v) ? v[0] : v + + setPreviewStartValue(val); + }} + onCommitPreviewValue={(e, v, thumb) => { + let val = Array.isArray(v) ? v[0] : v + + setPreviewStartValue(null); + if (props.onSetLoop) { + props.onSetLoop(val, props.loopEnable, props.loopStart, props.loopEnd); + } + }} + valueLabelFormat={(v) => formatTime(props.timebase, props.sampleRate, v)} + /> + + { + let t = props.start; + if (t > props.duration) { + t = props.duration; + if (props.onSetLoop) { + props.onSetLoop(t, props.loopEnable, props.loopStart, props.loopEnd); + } + } + }} + onValueChange={(e, val) => { + if (props.onSetLoop) { + props.onSetLoop(val, props.loopEnable, props.loopStart, props.loopEnd); + } + } + } + style={{ width: 120, alignSelf: "center", textAlign: "center" }} /> + + { + props.onSetLoop(props.start, !props.loopEnable, props.loopStart, props.loopEnd); + }} + + />} label="Enable loop" /> + + + { + if (!Array.isArray(v)) throw new Error("Expected array for loop start and end preview"); + if (props.onSetLoop) { + props.onSetLoop(props.start, props.loopEnable, v[0], v[1]); + } + }} + onPreview={(e, v, thumb) => { + if (!Array.isArray(v)) throw new Error("Expected array for loop start and end preview"); + setPreviewLoopStart(v[0]); + setPreviewLoopEnd(v[1]); + }} + onCommitPreviewValue={(e, v, thumb) => { + if (!Array.isArray(v)) throw new Error("Expected array for loop start and end preview"); + setPreviewLoopStart(null); + setPreviewLoopEnd(null); + props.onSetLoop(props.start, props.loopEnable, v[0], v[1]); + }} + + /> +
+ { + let tStart = props.loopStart; + if (tStart > props.duration) { + tStart = props.duration; + } + let tEnd = props.loopEnd; + if (tStart > tEnd) { + let t = tStart; + tStart = tEnd; + tEnd = t; + if (props.onSetLoop) { + props.onSetLoop(props.start, props.loopEnable, tStart, tEnd); + } + + } + }} + onValueChange={(e, val) => { + if (props.onSetLoop) { + props.onSetLoop(props.start, props.loopEnable, val, props.loopEnd); + } + }} + /> + { + let tStart = props.loopStart; + let tEnd = props.loopEnd;; + if (tEnd < tStart) { + let t = tEnd; + tEnd = tStart; + tStart = t; + if (props.onSetLoop) { + props.onSetLoop(props.start, props.loopEnable, tStart, tEnd); + } + } + }} + onValueChange={(e, val) => { + if (props.onSetLoop) { + props.onSetLoop(props.start, props.loopEnable, props.loopStart, val); + } + }} + /> +
+
+ +
+ + ) +} + diff --git a/vite/src/pipedal/PiPedalModel.tsx b/vite/src/pipedal/PiPedalModel.tsx index cf6253c..f5fd124 100644 --- a/vite/src/pipedal/PiPedalModel.tsx +++ b/vite/src/pipedal/PiPedalModel.tsx @@ -919,6 +919,7 @@ export class PiPedalModel //implements PiPedalModel } + async onSocketReconnected(): Promise { this.cancelOnNetworkChanging(); this.cancelAndroidReconnectTimer(); @@ -977,6 +978,7 @@ export class PiPedalModel //implements PiPedalModel } } + async getAudioFileMetadata(filePath: string): Promise { try { let url = @@ -1822,6 +1824,27 @@ export class PiPedalModel //implements PiPedalModel .request("setOnboarding", value); } + moveAudioFile( + path: string, + from: number, + to: number + ): Promise { + return new Promise( + (accept, reject) => { + this.webSocket?.request("moveAudioFile", { + path: path, + from: from, + to: to + }).then(() => { + accept(true); + }).catch((e) => { + this.setError(getErrorMessage(e)); + accept(false); + }); + } + ); + } + saveCurrentPresetAs(newName: string, saveAfterInstanceId = -1): Promise { // default behaviour is to save after the currently selected preset. @@ -3094,17 +3117,6 @@ export class PiPedalModel //implements PiPedalModel } - reorderAudioFiles( - path: string, - from: number, - to: number - ) { - this.webSocket?.send("reorderAudioFiles", { - path: path, - from: from, - to: to - }) - } }; let instance: PiPedalModel | undefined = undefined; diff --git a/vite/src/pipedal/Timebase.tsx b/vite/src/pipedal/Timebase.tsx new file mode 100644 index 0000000..a957af7 --- /dev/null +++ b/vite/src/pipedal/Timebase.tsx @@ -0,0 +1,52 @@ +/* + * 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, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * 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. + */ + +export enum TimebaseUnits { + Seconds = 0, + Samples = 1, + Beats = 2, +} + +export interface TimeSignature { + numerator: number; + denominator: number; +} + +export default interface Timebase { + units: TimebaseUnits; + tempo: number; + timeSignature: TimeSignature; +} + +export interface LoopParameters +{ + start: number; + loopEnable: boolean; + loopStart: number; + loopEnd: number; +}; + +export interface ToobPlayerSettings { + timebase: Timebase; + loopParameters: LoopParameters; +}; diff --git a/vite/src/pipedal/TimebaseselectorDialog.tsx b/vite/src/pipedal/TimebaseselectorDialog.tsx new file mode 100644 index 0000000..9c449e3 --- /dev/null +++ b/vite/src/pipedal/TimebaseselectorDialog.tsx @@ -0,0 +1,307 @@ +/* + * 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, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * 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, { useState } from 'react'; +import DialogEx from './DialogEx'; +import DialogTitle from '@mui/material/DialogTitle'; +import DialogContent from '@mui/material/DialogContent'; +import Button from '@mui/material/Button'; +import Select from '@mui/material/Select'; +import MenuItem from '@mui/material/MenuItem'; +import TextField, { StandardTextFieldProps } from '@mui/material/TextField'; +import Typography from '@mui/material/Typography'; +import Box from '@mui/material/Box'; +import Toolbar from '@mui/material/Toolbar'; +import IconButton from '@mui/material/IconButton'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import Timebase, { TimebaseUnits } from './Timebase'; + +interface TimebaseSelectorDialogProps { + timebase: Timebase; + onTimebaseChange: (timebase: Timebase) => void; +} + +interface NumericEditProps extends StandardTextFieldProps { + value: number; + onValueChange: (value: number) => void; + parse: (value: string) => number; + min: number; + max: number; + step?: number; + style?: React.CSSProperties; +} + +function NumericEdit(props: NumericEditProps) { + let { value, min, max, parse, step, onValueChange,style, ...extras } = props; + let [text, setText] = React.useState(value.toString()); + let [error, setError] = React.useState(false); + let [focus, setFocus] = React.useState(false); + + React.useEffect(() => { + if (!focus) { + setText(value.toString()); + setError(false); + } + }, [value]); + + const handleChange = (event: React.ChangeEvent) => { + let newValue = parse(event.target.value); + if (!isNaN(newValue) && newValue >= min && newValue <= max) { + setText(event.target.value); + onValueChange(newValue); + setError(false); + } else { + setText(event.target.value); + setError(true); + } + }; + return ( + { + setFocus(true); + e.target.select(); + }} + onBlur={() => { + setFocus(false); + let newValue = parse(text); + if (isNaN(newValue)) { + setText(value.toString()); + } else if (newValue < min) { + newValue = min; + setText(value.toString()); + onValueChange(newValue); + } else if (newValue > max) { + newValue = max; + setText(newValue.toString()); + onValueChange(value) + + } else { + setText(newValue.toString()); + onValueChange(newValue); + } + setError(false); + }} + /> + ); +} + + +export default function TimebaseSelectorDialog(props: TimebaseSelectorDialogProps) { + const { timebase, onTimebaseChange } = props; + const [open, setOpen] = useState(false); + const [editingTimebase, setEditingTimebase] = useState({ ...timebase }); + + const handleOpen = () => { + setEditingTimebase({ ...timebase }); + setOpen(true); + }; + + const handleClose = () => { + setOpen(false); + }; + const enableBeatControls = editingTimebase.units === TimebaseUnits.Beats; + + const handleChange = (timebase: Timebase) => { + onTimebaseChange(timebase); + }; + + const handleTimebaseTypeChange = (event: any) => { + let value = { + ...editingTimebase, + units: event.target.value as TimebaseUnits + }; + setEditingTimebase(value); + handleChange(value); + + }; + + const handleTempoChange = (tempo: number) => { + let value = { + ...editingTimebase, + tempo: tempo + }; + setEditingTimebase(value); + handleChange(value); + }; + + const handleTimeSignatureChange = (field: 'numerator' | 'denominator', numVal: number) => { + let value = { + ...editingTimebase, + timeSignature: { + ...editingTimebase.timeSignature, + [field]: numVal + } + }; + setEditingTimebase(value); + handleChange(value); + }; + + const getTimebaseTypeLabel = (timebase: Timebase): string => { + switch (timebase.units) { + case TimebaseUnits.Seconds: return 'Seconds'; + case TimebaseUnits.Samples: return 'Samples'; + case TimebaseUnits.Beats: + { + + return timebase.tempo.toString() + + " bpm (" + + timebase.timeSignature.numerator.toString() + + "/" + timebase.timeSignature.denominator.toString() + ")"; + } + default: return 'Unknown'; + } + }; + return ( + <> + + + { }} + > + + + { handleClose(); }} + > + + + + Timebase Settings + +
+ + + + + +
+ + Units + + + +
+ + Tempo (BPM) + + parseFloat(value)} + min={10} + max={400} + step={1} + onValueChange={handleTempoChange} + value={editingTimebase.tempo} + disabled={!enableBeatControls} + + variant="standard" + type="number" + style={{ width: 120, }} + /> + + Time Signature + +
+ + parseInt(value)} + min={1} + max={32} + step={1} + onValueChange={(value) => handleTimeSignatureChange('numerator', value)} + value={editingTimebase.timeSignature.numerator} + style={{ width: 80 }} + variant="standard" + type="number" + disabled={!enableBeatControls} + /> +  /  + + +
+
+
+
+
+ + + ); +} + + diff --git a/vite/src/pipedal/ToobPlayerControl.tsx b/vite/src/pipedal/ToobPlayerControl.tsx index 868e914..3633f7e 100644 --- a/vite/src/pipedal/ToobPlayerControl.tsx +++ b/vite/src/pipedal/ToobPlayerControl.tsx @@ -23,17 +23,19 @@ import React, { useEffect } from 'react'; import { styled } from '@mui/material/styles'; +import LoopDialog from './LoopDialog'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; +import Button from '@mui/material/Button'; import Slider from '@mui/material/Slider'; import IconButton from '@mui/material/IconButton'; import Pause from '@mui/icons-material/Pause'; import PlayArrow from '@mui/icons-material/PlayArrow'; import FastForward from '@mui/icons-material/FastForward'; import FastRewind from '@mui/icons-material/FastRewind'; -import { PiPedalModelFactory } from './PiPedalModel'; +import { PiPedalModelFactory, State } from './PiPedalModel'; import ButtonTooltip from './ButtonTooltip'; -import { pathFileNameOnly } from './FilePropertyDialog' +import { pathFileNameOnly } from './FileUtils'; import ButtonBase from '@mui/material/ButtonBase'; import FilePropertyDialog from './FilePropertyDialog'; import JsonAtom from './JsonAtom'; @@ -41,6 +43,8 @@ import { UiFileProperty } from './Lv2Plugin'; import { Divider } from '@mui/material'; import useWindowSize from './UseWindowSize'; import { getAlbumArtUri } from './AudioFileMetadata'; +import RepeatIcon from '@mui/icons-material/Repeat'; +import { LoopParameters, TimebaseUnits } from './Timebase'; let Player__seek = "http://two-play.com/plugins/toob-player#seek" const AUDIO_FILE_PROPERTY_URI = "http://two-play.com/plugins/toob-player#audioFile"; @@ -55,6 +59,7 @@ class PluginState { static Error = 6; }; +const useWallpaper = false; const WallPaper = styled('div')({ position: 'absolute', width: '100%', @@ -87,6 +92,14 @@ const WallPaper = styled('div')({ }, }); +function getAlbumLine(album: string, artist: string, albumArtist: string): string { + if (artist === "") { + artist = albumArtist; + } + let joiner = (artist !== "" && album !== "") ? " - " : ""; + return album + joiner + artist; + +} interface WidgetProps { noBorders: boolean; @@ -104,9 +117,9 @@ const WidgetBorders = styled('div')(({ theme }) => ({ position: 'relative', zIndex: 1, backgroundColor: 'rgba(255,255,255,0.4)', - boxShadow: "1px 3px 20px #0008", + boxShadow: "1px 4px 12px rgba(0,0,0,0.2)", ...theme.applyStyles('dark', { - backgroundColor: 'rgba(0,0,0,0.6)', + backgroundColor: useWallpaper? 'rgba(0,0,0,0.6)' : '#282828', }), })); @@ -121,12 +134,26 @@ const WidgetNoBorders = styled('div')(({ theme }) => ({ zIndex: 1, backgroundColor: 'rgba(255,255,255,0.4)', ...theme.applyStyles('dark', { - backgroundColor: 'rgba(0,0,0,0.6)', + backgroundColor: useWallpaper ? 'rgba(0,0,0,0.6)' : theme.mainBackground, }), })); +// const WidgetNoWallpaper = styled('div')(({ theme }) => ({ + +// padding: 16, +// borderRadius: 0, +// width: "100%", +// height: "100%", +// margin: 0, +// position: 'relative', +// zIndex: 1, + +// })); function Widget(props: WidgetProps) { + // if (!useWallpaper) { + // return({props.children} ); + // } if (props.noBorders) { return ({props.children} ); } else { @@ -163,9 +190,17 @@ export interface ToobPlayerControlProps { export default function ToobPlayerControl( props: ToobPlayerControlProps ) { + + const model = PiPedalModelFactory.getInstance(); + const defaultCoverArt = "/img/default_album.jpg"; + const [serverConnected, setServerConnected] = React.useState(model.state.get() == State.Ready); const [duration, setDuration] = React.useState(0.0); const [position, setPosition] = React.useState(0.0); + const [start, setStart] = React.useState(0.0); + const [loopStart, setLoopStart] = React.useState(0.0); + const [loopEnable, setLoopEnable] = React.useState(false); + const [loopEnd, setLoopEnd] = React.useState(0.0); const [dragging, setDragging] = React.useState(false); const [pluginState, setPluginState] = React.useState(0.0); const [sliderValue, setSliderValue] = React.useState(0.0); @@ -173,9 +208,17 @@ export default function ToobPlayerControl( const [audioFile, setAudioFile] = React.useState(""); const [title, setTitle] = React.useState(""); const [album, setAlbum] = React.useState(""); + const [artist, setArtist] = React.useState(""); + const [albumArtist, setAlbumArtist] = React.useState(""); const [showFileDialog, setShowFileDialog] = React.useState(false); + const [showLoopDialog, setShowLoopDialog] = React.useState(false); + const [timebase,setTimebase] = React.useState( + { + units: TimebaseUnits.Seconds, + tempo: 120.0, + timeSignature: { numerator: 4, denominator: 4 } + }); - const model = PiPedalModelFactory.getInstance(); const [size] = useWindowSize(); const width = size.width; @@ -185,8 +228,11 @@ export default function ToobPlayerControl( const useHorizontalLayout = height < HORIZONTAL_CONTROL_SCROLL_HEIGHT_BREAK && width > 573 && width > height; //const useVerticalScroll = width < 573; - const useQuadMixPanel = width < 720; const noBorders = width < 420 || height < 720; + let useQuadMixPanel = width < 720; + if (noBorders) { + useQuadMixPanel = width < 370; + } function SelectFile() { setShowFileDialog(true); @@ -202,6 +248,8 @@ export default function ToobPlayerControl( if (path === "") { setTitle(""); setAlbum(""); + setArtist(""); + setAlbumArtist(""); setCoverArt(defaultCoverArt); return; } @@ -221,6 +269,8 @@ export default function ToobPlayerControl( setCoverArt(coverArtUri); setTitle(strTrack + metadata.title); setAlbum(metadata.album); + setArtist(metadata.artist); + setAlbumArtist(metadata.albumArtist); }) .catch((e)=>{ setTitle("#error" + e.message); @@ -311,6 +361,10 @@ export default function ToobPlayerControl( { + e.preventDefault(); + }} + alt="Cover Art" src={ coverArt } @@ -324,10 +378,10 @@ export default function ToobPlayerControl( ) : (
- {effectiveTitle} + {titleLine} - {album} + {albumLine}
@@ -338,6 +392,16 @@ export default function ToobPlayerControl( ); } + function loopButtonText() { + if (start === 0 && !loopEnable) { + return "Set loop"; + + } else if (loopEnable) { + return `${formatDuration(start)} [${formatDuration(loopStart)} - ${formatDuration(loopEnd)}]`; + } else { + return `Start: ${formatDuration(start)}`; + } + } function getUiFileProperty(uri: string): UiFileProperty { let pedalboardItem = model.pedalboard.get().getItem(props.instanceId); let uiPlugin = model.getUiPlugin(pedalboardItem.uri); @@ -389,7 +453,17 @@ export default function ToobPlayerControl( }); setSliderValue(value); } + function onStateChanged(value: State) { + setServerConnected(value === State.Ready); + } useEffect(() => { + model.state.addOnChangedHandler(onStateChanged); + if (model.state.get() !== State.Ready) { + // wait for it. + return () => { + model.state.removeOnChangedHandler + } + } let durationHandle = model.monitorPort(props.instanceId, "duration", 1.0 / 15, (value) => { setDuration(value); @@ -400,6 +474,27 @@ export default function ToobPlayerControl( setPosition(value); } ); + // let startHandle = model.monitorPort(props.instanceId, "start", 1.0, + // (value) => { + // setStart(value); + // } + // ); + // let loopStartHandle = model.monitorPort(props.instanceId, "loopStart", 1.0, + // (value) => { + // setLoopStart(value); + // } + // ); + // let loopEnableHandle = model.monitorPort(props.instanceId, "loopEnable", 1.0, + // (value) => { + // setLoopEnable(value != 0); + // } + // ); + // let loopEndHandle = model.monitorPort(props.instanceId, "loopEnd", 1.0, + // (value) => { + // setLoopEnd(value); + // } + // ); + let pluginStateHandle = model.monitorPort(props.instanceId, "state", 1.0 / 1000.0, (value) => { setPluginState(value); @@ -428,17 +523,23 @@ export default function ToobPlayerControl( return () => { + model.state.removeOnChangedHandler(onStateChanged); model.unmonitorPort(durationHandle); model.unmonitorPort(positionHandle); model.unmonitorPort(pluginStateHandle); + // model.unmonitorPort(loopEnableHandle); + // model.unmonitorPort(startHandle); + // model.unmonitorPort(loopStartHandle); + // model.unmonitorPort(loopEndHandle); model.cancelMonitorPatchProperty(filePropertyHandle); }; }, - [] + [serverConnected] ); const effectivePosition = (pluginState !== PluginState.Idle) ? Math.min(position, duration) : 0.0; - const effectiveTitle = title !== "" ? title : pathFileNameOnly(audioFile); + const titleLine = title !== "" ? title : pathFileNameOnly(audioFile); + const albumLine = getAlbumLine(album,artist,albumArtist); const paused = (pluginState === PluginState.Idle || pluginState === PluginState.CuePlayPaused @@ -529,12 +630,24 @@ export default function ToobPlayerControl( flex: "0 0 auto", width: "100%", display: 'flex', - alignItems: 'center', + alignItems: 'top', justifyContent: 'space-between', marginTop: -4, }} > {formatDuration(dragging ? sliderValue : effectivePosition)} + {formatDuration(duration)}
@@ -554,69 +667,7 @@ export default function ToobPlayerControl( { FilePanel() } - { - let v = val as number; - setPosition(v); - setSliderValue(v); - }} - onChangeCommitted={(e, value) => { - let v = value as number; - OnSeek(v); - }} - onPointerDown={(e) => { - setDragging(true); - }} - onPointerUp={(e) => { - // setDragging(false); - }} - disabled={duration == 0} - sx={(t) => ({ - width: "100%", - color: 'rgba(0,0,0,0.87)', - height: 4, - '& .MuiSlider-thumb': { - width: 8, - height: 8, - transition: '0.3s cubic-bezier(.47,1.64,.41,.8)', - '&::before': { - boxShadow: '0 2px 12px 0 rgba(0,0,0,0.4)', - }, - '&:hover, &.Mui-focusVisible': { - boxShadow: `0px 0px 0px 8px ${'rgb(0 0 0 / 16%)'}`, - ...t.applyStyles('dark', { - boxShadow: `0px 0px 0px 8px ${'rgb(255 255 255 / 16%)'}`, - }), - }, - '&.Mui-active': { - width: 20, - height: 20, - }, - }, - '& .MuiSlider-rail': { - opacity: 0.28, - }, - ...t.applyStyles('dark', { - color: '#fff', - }), - })} - /> - - {formatDuration(dragging ? sliderValue : effectivePosition)} - {formatDuration(duration)} - + {SliderCluster()} { ControlCluster() } @@ -683,11 +734,76 @@ export default function ToobPlayerControl(
- + {useWallpaper && ()} { useHorizontalLayout ? HorizontalWidget() : VerticalWidget() } + {showLoopDialog && ( + { + setTimebase(newTimebase); + // model.setPatchProperty( + // props.instanceId, + // "timebase", + // newTimebase + // ); + }} + + onClose={() => { + setShowLoopDialog(false); + }} + onSetLoop={(start, loopEnable,loopStart, loopEnd) => { + + let loop: LoopParameters = { start: start, + loopEnable: loopEnable, + loopStart: loopStart, + loopEnd: loopEnd }; + + let loopSettings = { + timebase: timebase, + loopParameters: loop + }; + model.setPatchProperty( + props.instanceId, + "http://two-play.com/plugins/toob-player#loopSettings", + loopSettings.toString()); + + setStart(start); + // model.setPedalboardControl( + // props.instanceId, + // "start", + // start); + setLoopEnable(loopEnable); + // model.setPedalboardControl( + // props.instanceId, + // "loopEnable", + // loopEnable? 1.0: 0.0); + setLoopStart(loopStart); + // model.setPedalboardControl( + // props.instanceId, + // "loopStart", + // loopStart); + setLoopEnd(loopEnd); + // model.setPedalboardControl( + // props.instanceId, + // "loopEnd", + // loopEnd + // ); + }} + start={start} + loopStart={loopStart} + loopEnable={loopEnable} + loopEnd={loopEnd} + duration={duration} + /> + )} {showFileDialog && (