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 0000000..27e2326 Binary files /dev/null and b/docs/img/ubuntu.jpg differ 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 && (