Toob Player UI
This commit is contained in:
@@ -39,6 +39,17 @@ std::string HtmlHelper::timeToHttpDate()
|
|||||||
return timeToHttpDate(time(nullptr));
|
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<std::chrono::system_clock>(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)
|
std::string HtmlHelper::timeToHttpDate(time_t time)
|
||||||
{
|
{
|
||||||
// RFC 7231, IMF-fixdate.
|
// RFC 7231, IMF-fixdate.
|
||||||
|
|||||||
@@ -21,6 +21,8 @@
|
|||||||
|
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
|
#include <chrono>
|
||||||
|
#include <filesystem>
|
||||||
|
|
||||||
namespace pipedal {
|
namespace pipedal {
|
||||||
|
|
||||||
@@ -29,6 +31,7 @@ class HtmlHelper {
|
|||||||
public:
|
public:
|
||||||
static std::string timeToHttpDate();
|
static std::string timeToHttpDate();
|
||||||
static std::string timeToHttpDate(time_t time);
|
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);
|
static std::string encode_url_segment(const char*pStart, const char*pEnd, bool isQuerySegment = false);
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ namespace pipedal
|
|||||||
return contains(vector, std::string(value));
|
return contains(vector, std::string(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool HasWritePermissions(const std::filesystem::path &path);
|
||||||
class NoCopy
|
class NoCopy
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
@@ -114,11 +115,12 @@ namespace pipedal
|
|||||||
return true;
|
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);
|
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);
|
bool IsSubdirectory(const std::filesystem::path &path, const std::filesystem::path &basePath);
|
||||||
|
|
||||||
|
std::string ToLower(const std::string&value);
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
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.
|
// MUST not encode UTF16 surrogates in UTF8.
|
||||||
throw_encoding_error();
|
// throw_encoding_error();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (uc == '"' || uc == '\\')
|
if (uc == '"' || uc == '\\')
|
||||||
|
|||||||
@@ -216,3 +216,13 @@ bool pipedal::IsSubdirectory(const std::filesystem::path &path, const std::files
|
|||||||
}
|
}
|
||||||
return true;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
@@ -33,6 +33,8 @@ JSON_MAP_REFERENCE(AudioFileMetadata, lastModified)
|
|||||||
JSON_MAP_REFERENCE(AudioFileMetadata, title)
|
JSON_MAP_REFERENCE(AudioFileMetadata, title)
|
||||||
JSON_MAP_REFERENCE(AudioFileMetadata, track)
|
JSON_MAP_REFERENCE(AudioFileMetadata, track)
|
||||||
JSON_MAP_REFERENCE(AudioFileMetadata, album)
|
JSON_MAP_REFERENCE(AudioFileMetadata, album)
|
||||||
|
JSON_MAP_REFERENCE(AudioFileMetadata, albumArtist)
|
||||||
|
JSON_MAP_REFERENCE(AudioFileMetadata, artist)
|
||||||
|
|
||||||
JSON_MAP_REFERENCE(AudioFileMetadata, thumbnailType)
|
JSON_MAP_REFERENCE(AudioFileMetadata, thumbnailType)
|
||||||
JSON_MAP_REFERENCE(AudioFileMetadata, thumbnailFile)
|
JSON_MAP_REFERENCE(AudioFileMetadata, thumbnailFile)
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ namespace pipedal {
|
|||||||
std::string title_;
|
std::string title_;
|
||||||
int32_t track_ = -1; // track number, 0-based, -1 if not set
|
int32_t track_ = -1; // track number, 0-based, -1 if not set
|
||||||
std::string album_;
|
std::string album_;
|
||||||
|
std::string artist_;
|
||||||
|
std::string albumArtist_;
|
||||||
float duration_ = 0;
|
float duration_ = 0;
|
||||||
int32_t thumbnailType_ = 0; // 0 = unknown, 1 = embedded, 3 = folder, 4 = none
|
int32_t thumbnailType_ = 0; // 0 = unknown, 1 = embedded, 3 = folder, 4 = none
|
||||||
std::string thumbnailFile_; // only when thumbnailType is 3= folder.
|
std::string thumbnailFile_; // only when thumbnailType is 3= folder.
|
||||||
@@ -80,6 +82,8 @@ namespace pipedal {
|
|||||||
GETTER_SETTER_REF(title)
|
GETTER_SETTER_REF(title)
|
||||||
GETTER_SETTER_REF(track)
|
GETTER_SETTER_REF(track)
|
||||||
GETTER_SETTER_REF(album)
|
GETTER_SETTER_REF(album)
|
||||||
|
GETTER_SETTER_REF(albumArtist)
|
||||||
|
GETTER_SETTER_REF(artist)
|
||||||
GETTER_SETTER(duration)
|
GETTER_SETTER(duration)
|
||||||
ThumbnailType thumbnailType() const { return (ThumbnailType)thumbnailType_;}
|
ThumbnailType thumbnailType() const { return (ThumbnailType)thumbnailType_;}
|
||||||
void thumbnailType(ThumbnailType value) { thumbnailType_ = (int32_t)value; }
|
void thumbnailType(ThumbnailType value) { thumbnailType_ = (int32_t)value; }
|
||||||
|
|||||||
@@ -144,8 +144,12 @@ static int32_t MetadataTrackToInt(const std::string &track, const std::string &d
|
|||||||
int trackNumber = std::stoi(track);
|
int trackNumber = std::stoi(track);
|
||||||
if (!disc.empty())
|
if (!disc.empty())
|
||||||
{
|
{
|
||||||
int discNumber = std::stoi(disc);
|
if (disc != "" && disc != "1/1" && disc != "1")
|
||||||
trackNumber += discNumber + trackNumber; // e.g. disc 1 track 2 becomes 1002
|
{
|
||||||
|
int discNumber = std::stoi(disc);
|
||||||
|
trackNumber += discNumber*1000+ trackNumber; // e.g. disc 1 track 2 becomes 1002
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
return trackNumber;
|
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.
|
// not all of this data gets used (e.g. DATE, YEAR, ALBUM_ARTIST,TOTALTRACKS). But ... write once.
|
||||||
this->album_ = MetadataString(tags, {"ALBUM", "album"});
|
this->album_ = MetadataString(tags, {"ALBUM", "album"});
|
||||||
//this->artist_ = MetadataString(tags, {"ARTIST", "artist"});
|
this->artist_ = MetadataString(tags, {"ARTIST", "artist"});
|
||||||
//this->albumArtist_ = MetadataString(tags, {"ALBUM ARTIST", "album_artist", "album artist"});
|
this->albumArtist_ = MetadataString(tags, {"ALBUM ARTIST", "album_artist", "album artist"});
|
||||||
this->title_ = MetadataString(tags, {"TITLE", "title"});
|
this->title_ = MetadataString(tags, {"TITLE", "title"});
|
||||||
//this->date_ = MetadataString(tags, {"DATE", "date"});
|
//this->date_ = MetadataString(tags, {"DATE", "date"});
|
||||||
//this->year_ = MetadataString(tags, {"YEAR", "year"});
|
//this->year_ = MetadataString(tags, {"YEAR", "year"});
|
||||||
|
|||||||
+149
-30
@@ -30,6 +30,9 @@
|
|||||||
#include "MimeTypes.hpp"
|
#include "MimeTypes.hpp"
|
||||||
#include <stdexcept>
|
#include <stdexcept>
|
||||||
#include "AudioFilesDb.hpp"
|
#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.
|
#undef _GLIBCXX_DEBUG // Ensure we are not in debug mode, as this file is not compatible with it.
|
||||||
#include "SQLiteCpp/SQLiteCpp.h"
|
#include "SQLiteCpp/SQLiteCpp.h"
|
||||||
@@ -48,7 +51,7 @@ void AudioDirectoryInfo::SetResourceDirectory(const std::filesystem::path &path)
|
|||||||
{
|
{
|
||||||
resourceDirectory = path;
|
resourceDirectory = path;
|
||||||
}
|
}
|
||||||
std::filesystem::path AudioDirectoryInfo::GetTemporaryDirectory() const
|
std::filesystem::path AudioDirectoryInfo::GetTemporaryDirectory()
|
||||||
{
|
{
|
||||||
if (temporaryDirectory.empty())
|
if (temporaryDirectory.empty())
|
||||||
{
|
{
|
||||||
@@ -56,7 +59,7 @@ std::filesystem::path AudioDirectoryInfo::GetTemporaryDirectory() const
|
|||||||
}
|
}
|
||||||
return temporaryDirectory;
|
return temporaryDirectory;
|
||||||
}
|
}
|
||||||
std::filesystem::path AudioDirectoryInfo::GetResourceDirectory() const
|
std::filesystem::path AudioDirectoryInfo::GetResourceDirectory()
|
||||||
{
|
{
|
||||||
if (resourceDirectory.empty())
|
if (resourceDirectory.empty())
|
||||||
{
|
{
|
||||||
@@ -74,7 +77,9 @@ namespace
|
|||||||
|
|
||||||
static int64_t fileTimeToInt64(const fs::file_time_type &fileTime)
|
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<std::chrono::system_clock>(fileTime);
|
||||||
|
auto result = std::chrono::duration_cast<std::chrono::milliseconds>(system_time.time_since_epoch()).count();
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
static int64_t GetLastWriteTime(const fs::path &file)
|
static int64_t GetLastWriteTime(const fs::path &file)
|
||||||
{
|
{
|
||||||
@@ -91,9 +96,15 @@ namespace
|
|||||||
class AudioDirectoryInfoImpl : public AudioDirectoryInfo
|
class AudioDirectoryInfoImpl : public AudioDirectoryInfo
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
AudioDirectoryInfoImpl(const std::string &path)
|
AudioDirectoryInfoImpl(const std::filesystem::path &path, const std::filesystem::path&indexPath)
|
||||||
: path(path)
|
: path(path), indexPath(
|
||||||
|
(indexPath.empty() ? path: indexPath) / ".index.pipedal"
|
||||||
|
)
|
||||||
{
|
{
|
||||||
|
if (this->indexPath.parent_path() != path)
|
||||||
|
{
|
||||||
|
fs::create_directories(this->indexPath.parent_path());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
virtual ~AudioDirectoryInfoImpl() {}
|
virtual ~AudioDirectoryInfoImpl() {}
|
||||||
|
|
||||||
@@ -110,7 +121,16 @@ namespace
|
|||||||
virtual std::string GetNextAudioFile(const std::string &fileNameOnly) override;
|
virtual std::string GetNextAudioFile(const std::string &fileNameOnly) override;
|
||||||
virtual std::string GetPreviousAudioFile(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:
|
private:
|
||||||
|
std::vector<DbFileInfo> QueryTracks();
|
||||||
|
|
||||||
|
|
||||||
|
bool opened = false;
|
||||||
std::filesystem::path indexPath;
|
std::filesystem::path indexPath;
|
||||||
void OpenAudioDb();
|
void OpenAudioDb();
|
||||||
ThumbnailTemporaryFile GetUnindexedThunbnail(const std::string &fileNameOnly, int32_t width, int32_t height);
|
ThumbnailTemporaryFile GetUnindexedThunbnail(const std::string &fileNameOnly, int32_t width, int32_t height);
|
||||||
@@ -119,7 +139,6 @@ namespace
|
|||||||
fs::path path;
|
fs::path path;
|
||||||
using id_t = int64_t;
|
using id_t = int64_t;
|
||||||
|
|
||||||
std::vector<DbFileInfo> QueryTracks();
|
|
||||||
|
|
||||||
std::vector<DbFileInfo> UpdateDbFiles();
|
std::vector<DbFileInfo> UpdateDbFiles();
|
||||||
void DbSetThumbnailType(
|
void DbSetThumbnailType(
|
||||||
@@ -137,14 +156,23 @@ namespace
|
|||||||
void UpdateMetadata(DbFileInfo *dbFile);
|
void UpdateMetadata(DbFileInfo *dbFile);
|
||||||
static constexpr int DB_VERSION = 1;
|
static constexpr int DB_VERSION = 1;
|
||||||
std::filesystem::path GetFolderFile() const;
|
std::filesystem::path GetFolderFile() const;
|
||||||
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AudioDirectoryInfo::Ptr AudioDirectoryInfo::Create(const std::filesystem::path &path, const std::filesystem::path&indexPath)
|
||||||
AudioDirectoryInfo::Ptr AudioDirectoryInfo::Create(const std::string &path)
|
|
||||||
{
|
{
|
||||||
return std::shared_ptr<AudioDirectoryInfo>(
|
return std::make_shared<AudioDirectoryInfoImpl>(path,indexPath);
|
||||||
new AudioDirectoryInfoImpl(path));
|
}
|
||||||
|
|
||||||
|
|
||||||
|
std::vector<DbFileInfo> AudioDirectoryInfoImpl::QueryTracks()
|
||||||
|
{
|
||||||
|
if (audioFilesDb)
|
||||||
|
{
|
||||||
|
return audioFilesDb->QueryTracks();
|
||||||
|
}
|
||||||
|
return std::vector<DbFileInfo>();
|
||||||
}
|
}
|
||||||
|
|
||||||
std::vector<AudioFileMetadata> AudioDirectoryInfoImpl::GetFiles()
|
std::vector<AudioFileMetadata> AudioDirectoryInfoImpl::GetFiles()
|
||||||
@@ -160,14 +188,6 @@ std::vector<AudioFileMetadata> AudioDirectoryInfoImpl::GetFiles()
|
|||||||
return metadataResults;
|
return metadataResults;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::vector<DbFileInfo> AudioDirectoryInfoImpl::QueryTracks()
|
|
||||||
{
|
|
||||||
if (audioFilesDb)
|
|
||||||
{
|
|
||||||
return audioFilesDb->QueryTracks();
|
|
||||||
}
|
|
||||||
return std::vector<DbFileInfo>();
|
|
||||||
}
|
|
||||||
static bool isAudioExtension(const std::string &extension)
|
static bool isAudioExtension(const std::string &extension)
|
||||||
{
|
{
|
||||||
return MimeTypes::instance().AudioExtensions().contains(extension);
|
return MimeTypes::instance().AudioExtensions().contains(extension);
|
||||||
@@ -340,12 +360,6 @@ std::vector<DbFileInfo> AudioDirectoryInfoImpl::UpdateDbFiles()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!updateRequired && newFiles.empty())
|
|
||||||
{
|
|
||||||
// Nothing to do.
|
|
||||||
return dbFiles;
|
|
||||||
}
|
|
||||||
|
|
||||||
Locale::ptr locale = Locale::GetInstance();
|
Locale::ptr locale = Locale::GetInstance();
|
||||||
Collator::ptr collator = locale->GetCollator();
|
Collator::ptr collator = locale->GetCollator();
|
||||||
if (!HasPositionInfo(dbFiles))
|
if (!HasPositionInfo(dbFiles))
|
||||||
@@ -415,7 +429,8 @@ static std::vector<std::filesystem::path> COVER_ART_FILES = {
|
|||||||
"AlbumArt.jpg",
|
"AlbumArt.jpg",
|
||||||
"albumArt.jpg",
|
"albumArt.jpg",
|
||||||
"Frontcover.jpg",
|
"Frontcover.jpg",
|
||||||
"AlbumArtSmall.jpg"};
|
"AlbumArtSmall.jpg" // windows media player.
|
||||||
|
};
|
||||||
|
|
||||||
fs::path AudioDirectoryInfoImpl::GetFolderFile() const
|
fs::path AudioDirectoryInfoImpl::GetFolderFile() const
|
||||||
{
|
{
|
||||||
@@ -446,7 +461,7 @@ void AudioDirectoryInfoImpl::DbSetThumbnailType(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
void AudioDirectoryInfoImpl::DbSetThumbnailType(
|
void AudioDirectoryInfoImpl::DbSetThumbnailType(
|
||||||
const std::string&fileNameOnly,
|
const std::string &fileNameOnly,
|
||||||
ThumbnailType thumbnailType,
|
ThumbnailType thumbnailType,
|
||||||
const std::string &thumbnailFile,
|
const std::string &thumbnailFile,
|
||||||
int64_t thumbnailLastModified)
|
int64_t thumbnailLastModified)
|
||||||
@@ -614,6 +629,8 @@ void AudioDirectoryInfoImpl::UpdateMetadata(DbFileInfo *dbFile)
|
|||||||
dbFile->track(metadata.track());
|
dbFile->track(metadata.track());
|
||||||
dbFile->duration(metadata.duration());
|
dbFile->duration(metadata.duration());
|
||||||
dbFile->album(metadata.album());
|
dbFile->album(metadata.album());
|
||||||
|
dbFile->artist(metadata.artist());
|
||||||
|
dbFile->albumArtist(metadata.albumArtist());
|
||||||
dbFile->lastModified(GetLastWriteTime(file));
|
dbFile->lastModified(GetLastWriteTime(file));
|
||||||
dbFile->duration(metadata.duration());
|
dbFile->duration(metadata.duration());
|
||||||
}
|
}
|
||||||
@@ -656,8 +673,17 @@ size_t AudioDirectoryInfoImpl::TestGetNumberOfThumbnails()
|
|||||||
|
|
||||||
void AudioDirectoryInfoImpl::OpenAudioDb()
|
void AudioDirectoryInfoImpl::OpenAudioDb()
|
||||||
{
|
{
|
||||||
if (!this->audioFilesDb) {
|
|
||||||
this->audioFilesDb = std::make_shared<AudioFilesDb>(this->path, indexPath);
|
if (!this->audioFilesDb)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
this->audioFilesDb = std::make_shared<AudioFilesDb>(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;
|
return tempFile;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string AudioDirectoryInfoImpl::GetNextAudioFile(const std::string &fileNameOnly)
|
std::string AudioDirectoryInfoImpl::GetNextAudioFile(const std::string &fileNameOnly)
|
||||||
{
|
{
|
||||||
auto files = this->GetFiles();
|
auto files = this->GetFiles();
|
||||||
if (files.empty())
|
if (files.empty())
|
||||||
@@ -692,7 +718,7 @@ std::string AudioDirectoryInfoImpl::GetNextAudioFile(const std::string &fileName
|
|||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
std::string AudioDirectoryInfoImpl::GetPreviousAudioFile(const std::string &fileNameOnly)
|
std::string AudioDirectoryInfoImpl::GetPreviousAudioFile(const std::string &fileNameOnly)
|
||||||
{
|
{
|
||||||
auto files = this->GetFiles();
|
auto files = this->GetFiles();
|
||||||
if (files.empty())
|
if (files.empty())
|
||||||
@@ -715,3 +741,96 @@ std::string AudioDirectoryInfoImpl::GetPreviousAudioFile(const std::string &file
|
|||||||
}
|
}
|
||||||
return "";
|
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<int32_t>(files.size()) ||
|
||||||
|
toPosition > static_cast<int32_t>(files.size()))
|
||||||
|
{
|
||||||
|
throw std::invalid_argument("Invalid arguments for MoveAudioFile");
|
||||||
|
}
|
||||||
|
std::shared_ptr<SQLite::Transaction> 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<int32_t>(i))
|
||||||
|
{
|
||||||
|
file.position(static_cast<int32_t>(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+59
-37
@@ -8,10 +8,10 @@
|
|||||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
* copies of the Software, and to permit persons to whom the Software is
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
* furnished to do so, subject to the following conditions:
|
* furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
* The above copyright notice and this permission notice shall be included in all
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
* copies or substantial portions of the Software.
|
* copies or substantial portions of the Software.
|
||||||
|
|
||||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
* SOFTWARE.
|
* SOFTWARE.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
@@ -31,75 +31,97 @@
|
|||||||
#include "AudioFileMetadata.hpp"
|
#include "AudioFileMetadata.hpp"
|
||||||
#include "TemporaryFile.hpp"
|
#include "TemporaryFile.hpp"
|
||||||
|
|
||||||
namespace pipedal {
|
namespace pipedal
|
||||||
|
{
|
||||||
|
|
||||||
class ThumbnailTemporaryFile: public TemporaryFile {
|
class ThumbnailTemporaryFile : public TemporaryFile
|
||||||
|
{
|
||||||
private:
|
private:
|
||||||
// Private constructor to enforce the use of CreateTemporaryFile or the other constructor.
|
// 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:
|
public:
|
||||||
using super = TemporaryFile;
|
using super = TemporaryFile;
|
||||||
ThumbnailTemporaryFile() = default;
|
ThumbnailTemporaryFile() = default;
|
||||||
ThumbnailTemporaryFile(const ThumbnailTemporaryFile&) = delete;
|
ThumbnailTemporaryFile(const ThumbnailTemporaryFile &) = delete;
|
||||||
ThumbnailTemporaryFile(ThumbnailTemporaryFile&&) = default;
|
ThumbnailTemporaryFile(ThumbnailTemporaryFile &&) = default;
|
||||||
ThumbnailTemporaryFile&operator=(const ThumbnailTemporaryFile&) = delete;
|
ThumbnailTemporaryFile &operator=(const ThumbnailTemporaryFile &) = delete;
|
||||||
ThumbnailTemporaryFile&operator=(ThumbnailTemporaryFile&&) = default;
|
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:
|
|
||||||
|
|
||||||
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);
|
TemporaryFile::SetNonDeletedPath(path);
|
||||||
// Set the MIME type for audio files.
|
// Set the MIME type for audio files.
|
||||||
this->mimeType = mimeType; // Default MIME type, can be set later.
|
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;
|
return mimeType;
|
||||||
}
|
}
|
||||||
void SetMimeType(const std::string&mimeType) {
|
void SetMimeType(const std::string &mimeType)
|
||||||
|
{
|
||||||
this->mimeType = mimeType;
|
this->mimeType = mimeType;
|
||||||
}
|
}
|
||||||
private:
|
|
||||||
|
private:
|
||||||
std::string mimeType = "image/jpeg"; // Default MIME type, can be set later.
|
std::string mimeType = "image/jpeg"; // Default MIME type, can be set later.
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
class AudioDirectoryInfo {
|
class AudioDirectoryInfo
|
||||||
|
{
|
||||||
protected:
|
protected:
|
||||||
AudioDirectoryInfo() { }
|
AudioDirectoryInfo() {}
|
||||||
virtual ~AudioDirectoryInfo() { }
|
virtual ~AudioDirectoryInfo() {}
|
||||||
|
|
||||||
public:
|
public:
|
||||||
using self = AudioDirectoryInfo;
|
using self = AudioDirectoryInfo;
|
||||||
using Ptr = std::shared_ptr<self>;
|
using Ptr = std::shared_ptr<self>;
|
||||||
|
|
||||||
static Ptr Create(const std::string&path);
|
static Ptr Create(
|
||||||
|
const std::filesystem::path &path,
|
||||||
|
const std::filesystem::path &indexPath = "");
|
||||||
|
|
||||||
virtual std::vector<AudioFileMetadata> GetFiles() = 0;
|
virtual std::vector<AudioFileMetadata> 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 GetNextAudioFile(const std::string &fileNameOnly) = 0;
|
||||||
virtual std::string GetPreviousAudioFile(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;
|
virtual ThumbnailTemporaryFile DefaultThumbnailTemporaryFile() = 0;
|
||||||
|
|
||||||
static void SetTemporaryDirectory(const std::filesystem::path&path);
|
static void SetTemporaryDirectory(const std::filesystem::path &path);
|
||||||
static void SetResourceDirectory(const std::filesystem::path&path);
|
static void SetResourceDirectory(const std::filesystem::path &path);
|
||||||
|
|
||||||
|
|
||||||
virtual size_t TestGetNumberOfThumbnails() = 0; // test use only.
|
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:
|
public:
|
||||||
std::filesystem::path GetTemporaryDirectory() const;
|
static std::filesystem::path GetTemporaryDirectory();
|
||||||
std::filesystem::path GetResourceDirectory() const;
|
static std::filesystem::path GetResourceDirectory();
|
||||||
|
|
||||||
private:
|
private:
|
||||||
static std::filesystem::path temporaryDirectory;
|
static std::filesystem::path temporaryDirectory;
|
||||||
static std::filesystem::path resourceDirectory;;
|
static std::filesystem::path resourceDirectory;
|
||||||
|
;
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
}
|
||||||
+44
-23
@@ -78,7 +78,6 @@ namespace pipedal::impl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
lockEntry->mutex.lock(); // we now have exclusive access to the database.
|
lockEntry->mutex.lock(); // we now have exclusive access to the database.
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
~DatabaseLock()
|
~DatabaseLock()
|
||||||
@@ -93,9 +92,7 @@ namespace pipedal::impl
|
|||||||
// Remove the entry from the map if no other locks are held.
|
// Remove the entry from the map if no other locks are held.
|
||||||
databaseLocks.erase(indexFile);
|
databaseLocks.erase(indexFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -178,6 +175,8 @@ void AudioFilesDb::CreateDb(const std::filesystem::path &dbPathName)
|
|||||||
"title TEXT NOT NULL,"
|
"title TEXT NOT NULL,"
|
||||||
"track NUMBER NOT NULL,"
|
"track NUMBER NOT NULL,"
|
||||||
"album TEXT NOT NULL, "
|
"album TEXT NOT NULL, "
|
||||||
|
"artist TEXT NOT NULL, "
|
||||||
|
"albumArtist TEXT NOT NULL, "
|
||||||
"duration REAL NOT NULL DEFAULT 0.0,"
|
"duration REAL NOT NULL DEFAULT 0.0,"
|
||||||
|
|
||||||
"thumbnailType INTEGER NOT NULL DEFAULT 0,"
|
"thumbnailType INTEGER NOT NULL DEFAULT 0,"
|
||||||
@@ -292,7 +291,7 @@ std::vector<DbFileInfo> AudioFilesDb::QueryTracks()
|
|||||||
SQLite::Statement query(
|
SQLite::Statement query(
|
||||||
*db,
|
*db,
|
||||||
"SELECT idFile, fileName, "
|
"SELECT idFile, fileName, "
|
||||||
"lastModified, title, track, album,duration, "
|
"lastModified, title, track, album, artist,albumArtist,duration, "
|
||||||
"thumbnailType, position, "
|
"thumbnailType, position, "
|
||||||
"thumbnailFile, thumbnailLastModified "
|
"thumbnailFile, thumbnailLastModified "
|
||||||
"FROM files ");
|
"FROM files ");
|
||||||
@@ -306,11 +305,13 @@ std::vector<DbFileInfo> AudioFilesDb::QueryTracks()
|
|||||||
row.title(query.getColumn(3).getText());
|
row.title(query.getColumn(3).getText());
|
||||||
row.track(query.getColumn(4).getInt());
|
row.track(query.getColumn(4).getInt());
|
||||||
row.album(query.getColumn(5).getText());
|
row.album(query.getColumn(5).getText());
|
||||||
row.duration(query.getColumn(6).getDouble());
|
row.artist(query.getColumn(6).getText());
|
||||||
row.thumbnailType((ThumbnailType)query.getColumn(7).getInt());
|
row.albumArtist(query.getColumn(7).getText());
|
||||||
row.position(query.getColumn(8).getInt());
|
row.duration(query.getColumn(8).getDouble());
|
||||||
row.thumbnailFile(query.getColumn(9).getText());
|
row.thumbnailType((ThumbnailType)query.getColumn(9).getInt());
|
||||||
row.thumbnailLastModified(query.getColumn(10).getInt64());
|
row.position(query.getColumn(10).getInt());
|
||||||
|
row.thumbnailFile(query.getColumn(11).getText());
|
||||||
|
row.thumbnailLastModified(query.getColumn(12).getInt64());
|
||||||
result.push_back(std::move(row));
|
result.push_back(std::move(row));
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
@@ -326,9 +327,9 @@ void AudioFilesDb::WriteFile(DbFileInfo *dbFile)
|
|||||||
*db,
|
*db,
|
||||||
"INSERT INTO files ("
|
"INSERT INTO files ("
|
||||||
"fileName, lastModified,"
|
"fileName, lastModified,"
|
||||||
"title,track,album, "
|
"title,track,album,artist, albumArtist, "
|
||||||
"duration, thumbnailType,thumbnailFile, thumbnailLastModified, position "
|
"duration, thumbnailType,thumbnailFile, thumbnailLastModified, position "
|
||||||
") VALUES (?, ?, ?, ?, ?,?,?,?,?,?)");
|
") VALUES (?, ?, ?, ?, ?,?,?,?,?,?,?,?)");
|
||||||
}
|
}
|
||||||
insertFileQuery->tryReset();
|
insertFileQuery->tryReset();
|
||||||
insertFileQuery->bind(1, dbFile->fileName());
|
insertFileQuery->bind(1, dbFile->fileName());
|
||||||
@@ -336,11 +337,13 @@ void AudioFilesDb::WriteFile(DbFileInfo *dbFile)
|
|||||||
insertFileQuery->bind(3, dbFile->title());
|
insertFileQuery->bind(3, dbFile->title());
|
||||||
insertFileQuery->bind(4, dbFile->track());
|
insertFileQuery->bind(4, dbFile->track());
|
||||||
insertFileQuery->bind(5, dbFile->album());
|
insertFileQuery->bind(5, dbFile->album());
|
||||||
insertFileQuery->bind(6, dbFile->duration());
|
insertFileQuery->bind(6, dbFile->artist());
|
||||||
insertFileQuery->bind(7, (int32_t)dbFile->thumbnailType());
|
insertFileQuery->bind(7, dbFile->albumArtist());
|
||||||
insertFileQuery->bind(8, dbFile->thumbnailFile());
|
insertFileQuery->bind(8, dbFile->duration());
|
||||||
insertFileQuery->bind(9, dbFile->thumbnailLastModified());
|
insertFileQuery->bind(9, (int32_t)dbFile->thumbnailType());
|
||||||
insertFileQuery->bind(10, dbFile->position());
|
insertFileQuery->bind(10, dbFile->thumbnailFile());
|
||||||
|
insertFileQuery->bind(11, dbFile->thumbnailLastModified());
|
||||||
|
insertFileQuery->bind(12, dbFile->position());
|
||||||
insertFileQuery->exec();
|
insertFileQuery->exec();
|
||||||
dbFile->idFile(db->getLastInsertRowid());
|
dbFile->idFile(db->getLastInsertRowid());
|
||||||
}
|
}
|
||||||
@@ -352,7 +355,7 @@ void AudioFilesDb::WriteFile(DbFileInfo *dbFile)
|
|||||||
*db,
|
*db,
|
||||||
"UPDATE files SET "
|
"UPDATE files SET "
|
||||||
"fileName = ?, lastModified = ?, "
|
"fileName = ?, lastModified = ?, "
|
||||||
"title = ?, track = ?, album = ?, "
|
"title = ?, track = ?, album = ?, artist = ? , albumArtist = ?, "
|
||||||
"duration = ?, thumbnailType = ?, "
|
"duration = ?, thumbnailType = ?, "
|
||||||
"thumbnailFile = ?, thumbnailLastModified = ?, position = ? "
|
"thumbnailFile = ?, thumbnailLastModified = ?, position = ? "
|
||||||
" WHERE idFile = ?");
|
" WHERE idFile = ?");
|
||||||
@@ -363,13 +366,15 @@ void AudioFilesDb::WriteFile(DbFileInfo *dbFile)
|
|||||||
updateFileQuery->bind(3, dbFile->title());
|
updateFileQuery->bind(3, dbFile->title());
|
||||||
updateFileQuery->bind(4, dbFile->track());
|
updateFileQuery->bind(4, dbFile->track());
|
||||||
updateFileQuery->bind(5, dbFile->album());
|
updateFileQuery->bind(5, dbFile->album());
|
||||||
updateFileQuery->bind(6, dbFile->duration());
|
updateFileQuery->bind(6, dbFile->artist());
|
||||||
updateFileQuery->bind(7, (int32_t)dbFile->thumbnailType());
|
updateFileQuery->bind(7, dbFile->albumArtist());
|
||||||
updateFileQuery->bind(8, dbFile->thumbnailFile());
|
updateFileQuery->bind(8, dbFile->duration());
|
||||||
updateFileQuery->bind(9, dbFile->thumbnailLastModified());
|
updateFileQuery->bind(9, (int32_t)dbFile->thumbnailType());
|
||||||
updateFileQuery->bind(10, dbFile->position());
|
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();
|
updateFileQuery->exec();
|
||||||
}
|
}
|
||||||
@@ -480,3 +485,19 @@ void AudioFilesDb::AddThumbnail(
|
|||||||
throw std::runtime_error("Failed to add thumbnail: " + std::string(e.what()));
|
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<SQLite::Statement>(
|
||||||
|
*db,
|
||||||
|
"UPDATE files SET position = ? WHERE idFile = ?");
|
||||||
|
}
|
||||||
|
updateFileQuery->tryReset();
|
||||||
|
updateFileQuery->bind(1, position);
|
||||||
|
updateFileQuery->bind(2, idFile);
|
||||||
|
updateFileQuery->exec();
|
||||||
|
}
|
||||||
|
|||||||
@@ -106,6 +106,9 @@ namespace pipedal::impl {
|
|||||||
int64_t width,
|
int64_t width,
|
||||||
int64_t height,
|
int64_t height,
|
||||||
const std::vector<uint8_t> &thumbnailData);
|
const std::vector<uint8_t> &thumbnailData);
|
||||||
|
void UpdateFilePosition(
|
||||||
|
int64_t idFile,
|
||||||
|
int32_t position);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
|
||||||
@@ -124,6 +127,7 @@ namespace pipedal::impl {
|
|||||||
std::unique_ptr<SQLite::Statement> deleteFileQuery;
|
std::unique_ptr<SQLite::Statement> deleteFileQuery;
|
||||||
std::unique_ptr<SQLite::Statement> updateThumbnailInfoQueryByName;
|
std::unique_ptr<SQLite::Statement> updateThumbnailInfoQueryByName;
|
||||||
std::unique_ptr<SQLite::Statement> updateThumbnailInfoQueryById;
|
std::unique_ptr<SQLite::Statement> updateThumbnailInfoQueryById;
|
||||||
|
std::unique_ptr<SQLite::Statement> updatePositionQuery;
|
||||||
std::filesystem::path path;
|
std::filesystem::path path;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@
|
|||||||
#include "DBusLog.hpp"
|
#include "DBusLog.hpp"
|
||||||
#include "AvahiService.hpp"
|
#include "AvahiService.hpp"
|
||||||
#include "DummyAudioDriver.hpp"
|
#include "DummyAudioDriver.hpp"
|
||||||
|
#include "AudioFiles.hpp"
|
||||||
|
|
||||||
#ifndef NO_MLOCK
|
#ifndef NO_MLOCK
|
||||||
#include <sys/mman.h>
|
#include <sys/mman.h>
|
||||||
@@ -2921,4 +2922,17 @@ void PiPedalModel::OnAlsaDriverTerminatedAbnormally() {
|
|||||||
bool PiPedalModel::IsInUploadsDirectory(const std::string &path)
|
bool PiPedalModel::IsInUploadsDirectory(const std::string &path)
|
||||||
{
|
{
|
||||||
return storage.IsInUploadsDirectory(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);
|
||||||
}
|
}
|
||||||
@@ -462,6 +462,11 @@ namespace pipedal
|
|||||||
uint64_t CreateNewPreset();
|
uint64_t CreateNewPreset();
|
||||||
|
|
||||||
bool LoadCurrentPedalboard();
|
bool LoadCurrentPedalboard();
|
||||||
|
|
||||||
|
void MoveAudioFile(
|
||||||
|
const std::string & path,
|
||||||
|
int32_t from,
|
||||||
|
int32_t to);
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace pipedal.
|
} // namespace pipedal.
|
||||||
+45
-22
@@ -60,7 +60,6 @@ JSON_MAP_REFERENCE(PathPatchPropertyChangedBody, propertyUri)
|
|||||||
JSON_MAP_REFERENCE(PathPatchPropertyChangedBody, atomJson)
|
JSON_MAP_REFERENCE(PathPatchPropertyChangedBody, atomJson)
|
||||||
JSON_MAP_END()
|
JSON_MAP_END()
|
||||||
|
|
||||||
|
|
||||||
class GetPatchPropertyBody
|
class GetPatchPropertyBody
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
@@ -74,6 +73,20 @@ JSON_MAP_REFERENCE(GetPatchPropertyBody, instanceId)
|
|||||||
JSON_MAP_REFERENCE(GetPatchPropertyBody, propertyUri)
|
JSON_MAP_REFERENCE(GetPatchPropertyBody, propertyUri)
|
||||||
JSON_MAP_END()
|
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
|
class CreateNewSampleDirectoryArgs
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
@@ -111,14 +124,11 @@ public:
|
|||||||
DECLARE_JSON_MAP(GetFilePropertyDirectoryTreeArgs);
|
DECLARE_JSON_MAP(GetFilePropertyDirectoryTreeArgs);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
JSON_MAP_BEGIN(GetFilePropertyDirectoryTreeArgs)
|
JSON_MAP_BEGIN(GetFilePropertyDirectoryTreeArgs)
|
||||||
JSON_MAP_REFERENCE(GetFilePropertyDirectoryTreeArgs, fileProperty)
|
JSON_MAP_REFERENCE(GetFilePropertyDirectoryTreeArgs, fileProperty)
|
||||||
JSON_MAP_REFERENCE(GetFilePropertyDirectoryTreeArgs, selectedPath)
|
JSON_MAP_REFERENCE(GetFilePropertyDirectoryTreeArgs, selectedPath)
|
||||||
JSON_MAP_END()
|
JSON_MAP_END()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class Lv2StateChangedBody
|
class Lv2StateChangedBody
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
@@ -413,7 +423,8 @@ JSON_MAP_REFERENCE(SetSnapshotsBody, snapshots)
|
|||||||
JSON_MAP_REFERENCE(SetSnapshotsBody, selectedSnapshot)
|
JSON_MAP_REFERENCE(SetSnapshotsBody, selectedSnapshot)
|
||||||
JSON_MAP_END()
|
JSON_MAP_END()
|
||||||
|
|
||||||
class SnapshotModifiedBody {
|
class SnapshotModifiedBody
|
||||||
|
{
|
||||||
public:
|
public:
|
||||||
int64_t snapshotIndex_;
|
int64_t snapshotIndex_;
|
||||||
bool modified_;
|
bool modified_;
|
||||||
@@ -582,7 +593,6 @@ public:
|
|||||||
{
|
{
|
||||||
FinalCleanup();
|
FinalCleanup();
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bool finalCleanup = false;
|
bool finalCleanup = false;
|
||||||
@@ -1100,7 +1110,7 @@ public:
|
|||||||
else if (message == "getHasWifi")
|
else if (message == "getHasWifi")
|
||||||
{
|
{
|
||||||
bool result = model.GetHasWifi();
|
bool result = model.GetHasWifi();
|
||||||
this->Reply(replyTo, "getHasWifi",result);
|
this->Reply(replyTo, "getHasWifi", result);
|
||||||
}
|
}
|
||||||
else if (message == "updateNow")
|
else if (message == "updateNow")
|
||||||
{
|
{
|
||||||
@@ -1473,13 +1483,8 @@ public:
|
|||||||
SetPatchPropertyBody body;
|
SetPatchPropertyBody body;
|
||||||
pReader->read(&body);
|
pReader->read(&body);
|
||||||
model.SendSetPatchProperty(clientId, body.instanceId_, body.propertyUri_, body.value_, [this, replyTo]()
|
model.SendSetPatchProperty(clientId, body.instanceId_, body.propertyUri_, body.value_, [this, replyTo]()
|
||||||
{
|
{ this->JsonReply(replyTo, "setPatchProperty", "true"); }, [this, replyTo](const std::string &error)
|
||||||
this->JsonReply(replyTo, "setPatchProperty", "true");
|
{ this->SendError(replyTo, error.c_str()); });
|
||||||
},
|
|
||||||
[this, replyTo](const std::string &error)
|
|
||||||
{
|
|
||||||
this->SendError(replyTo, error.c_str());
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
else if (message == "getPatchProperty")
|
else if (message == "getPatchProperty")
|
||||||
@@ -1644,13 +1649,31 @@ public:
|
|||||||
{
|
{
|
||||||
GetFilePropertyDirectoryTreeArgs args;
|
GetFilePropertyDirectoryTreeArgs args;
|
||||||
pReader->read(&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(
|
model.GetFilePropertydirectoryTree(
|
||||||
args.fileProperty_,
|
args.fileProperty_,
|
||||||
args.selectedPath_);
|
args.selectedPath_);
|
||||||
this->Reply(replyTo, "GetFilePropertydirectoryTree", result);
|
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")
|
else if (message == "setOnboarding")
|
||||||
{
|
{
|
||||||
bool value;
|
bool value;
|
||||||
@@ -1659,7 +1682,7 @@ public:
|
|||||||
}
|
}
|
||||||
else if (message == "getWifiRegulatoryDomains")
|
else if (message == "getWifiRegulatoryDomains")
|
||||||
{
|
{
|
||||||
auto regulatoryDomains = this->model.GetWifiRegulatoryDomains();
|
auto regulatoryDomains = this->model.GetWifiRegulatoryDomains();
|
||||||
this->Reply(replyTo, "getWifiRegulatoryDomains", regulatoryDomains);
|
this->Reply(replyTo, "getWifiRegulatoryDomains", regulatoryDomains);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -1670,7 +1693,9 @@ public:
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
virtual void onSocketClosed() override {
|
virtual void
|
||||||
|
onSocketClosed() override
|
||||||
|
{
|
||||||
SocketHandler::OnSocketClosed();
|
SocketHandler::OnSocketClosed();
|
||||||
this->Close();
|
this->Close();
|
||||||
}
|
}
|
||||||
@@ -1754,10 +1779,10 @@ private:
|
|||||||
Send("onLv2PluginsChanging", true);
|
Send("onLv2PluginsChanging", true);
|
||||||
Flush();
|
Flush();
|
||||||
}
|
}
|
||||||
virtual void OnHasWifiChanged(bool hasWifi){
|
virtual void OnHasWifiChanged(bool hasWifi)
|
||||||
|
{
|
||||||
Send("onHasWifiChanged", hasWifi);
|
Send("onHasWifiChanged", hasWifi);
|
||||||
Flush();
|
Flush();
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
virtual void OnNetworkChanging(bool hotspotConnected) override
|
virtual void OnNetworkChanging(bool hotspotConnected) override
|
||||||
@@ -1809,7 +1834,7 @@ private:
|
|||||||
Send("onChannelSelectionChanged", body);
|
Send("onChannelSelectionChanged", body);
|
||||||
}
|
}
|
||||||
|
|
||||||
virtual void OnSnapshotModified(int64_t snapshotIndex, bool modified)
|
virtual void OnSnapshotModified(int64_t snapshotIndex, bool modified)
|
||||||
{
|
{
|
||||||
SnapshotModifiedBody body;
|
SnapshotModifiedBody body;
|
||||||
body.snapshotIndex_ = snapshotIndex;
|
body.snapshotIndex_ = snapshotIndex;
|
||||||
@@ -2124,7 +2149,6 @@ private:
|
|||||||
|
|
||||||
std::atomic<uint64_t> PiPedalSocketHandler::nextClientId = 0;
|
std::atomic<uint64_t> PiPedalSocketHandler::nextClientId = 0;
|
||||||
|
|
||||||
|
|
||||||
class PiPedalSocketFactory : public ISocketFactory
|
class PiPedalSocketFactory : public ISocketFactory
|
||||||
{
|
{
|
||||||
private:
|
private:
|
||||||
@@ -2152,7 +2176,6 @@ public:
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
std::shared_ptr<ISocketFactory> pipedal::MakePiPedalSocketFactory(PiPedalModel &model)
|
std::shared_ptr<ISocketFactory> pipedal::MakePiPedalSocketFactory(PiPedalModel &model)
|
||||||
{
|
{
|
||||||
return std::make_shared<PiPedalSocketFactory>(model);
|
return std::make_shared<PiPedalSocketFactory>(model);
|
||||||
|
|||||||
+19
-3
@@ -1689,6 +1689,7 @@ static void AddFilesToResult(
|
|||||||
}
|
}
|
||||||
|
|
||||||
static void AddTracksToResult(
|
static void AddTracksToResult(
|
||||||
|
const fs::path &audioRootDirectory,
|
||||||
FileRequestResult &result,
|
FileRequestResult &result,
|
||||||
const ModFileTypes::ModDirectory *modDirectoryInfo, // yyx
|
const ModFileTypes::ModDirectory *modDirectoryInfo, // yyx
|
||||||
const UiFileProperty &fileProperty,
|
const UiFileProperty &fileProperty,
|
||||||
@@ -1716,7 +1717,22 @@ static void AddTracksToResult(
|
|||||||
resultFiles.push_back(FileEntry{path, name, true, fs::is_symlink(path)});
|
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())
|
for (const auto &audioFile : audioFiles->GetFiles())
|
||||||
{
|
{
|
||||||
fs::path audioFilePath = rootPath / audioFile.fileName();
|
fs::path audioFilePath = rootPath / audioFile.fileName();
|
||||||
@@ -1821,7 +1837,7 @@ FileRequestResult Storage::GetModFileList2(const std::string &relativePath, cons
|
|||||||
|
|
||||||
if (IsInAudioTracksDirectory(relativePath))
|
if (IsInAudioTracksDirectory(relativePath))
|
||||||
{
|
{
|
||||||
AddTracksToResult(result, rootModDirectory, fileProperty, relativePath);
|
AddTracksToResult(this->GetPluginUploadDirectory(),result, rootModDirectory, fileProperty, relativePath);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -1921,7 +1937,7 @@ FileRequestResult Storage::GetFileList2(const std::string &relativePath_, const
|
|||||||
|
|
||||||
if (IsInAudioTracksDirectory(absolutePath))
|
if (IsInAudioTracksDirectory(absolutePath))
|
||||||
{
|
{
|
||||||
AddTracksToResult(result, pModDirectory, fileProperty, absolutePath);
|
AddTracksToResult(GetPluginUploadDirectory(), result, pModDirectory, fileProperty, absolutePath);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
+1
-1
@@ -63,7 +63,7 @@ public:
|
|||||||
{
|
{
|
||||||
set("");
|
set("");
|
||||||
}
|
}
|
||||||
const std::string& str() {
|
const std::string& str() const {
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
uri(const char*text)
|
uri(const char*text)
|
||||||
|
|||||||
+48
-9
@@ -37,6 +37,7 @@
|
|||||||
#include "MimeTypes.hpp"
|
#include "MimeTypes.hpp"
|
||||||
#include "AudioFileMetadataReader.hpp"
|
#include "AudioFileMetadataReader.hpp"
|
||||||
#include "AudioFiles.hpp"
|
#include "AudioFiles.hpp"
|
||||||
|
#include "util.hpp"
|
||||||
|
|
||||||
#define OLD_PRESET_EXTENSION ".piPreset"
|
#define OLD_PRESET_EXTENSION ".piPreset"
|
||||||
#define 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 PRESET_MIME_TYPE = "application/vnd.pipedal.preset";
|
||||||
static const std::string BANK_MIME_TYPE = "application/vnd.pipedal.bank";
|
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 pipedal;
|
||||||
using namespace boost::system;
|
using namespace boost::system;
|
||||||
namespace fs = std::filesystem;
|
namespace fs = std::filesystem;
|
||||||
@@ -137,6 +142,7 @@ private:
|
|||||||
std::vector<std::string> extensions;
|
std::vector<std::string> extensions;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
class DownloadIntercept : public RequestHandler
|
class DownloadIntercept : public RequestHandler
|
||||||
{
|
{
|
||||||
PiPedalModel *model;
|
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(
|
virtual void get_response(
|
||||||
const uri &request_uri,
|
const uri &request_uri,
|
||||||
HttpRequest &req,
|
HttpRequest &req,
|
||||||
@@ -493,8 +513,7 @@ public:
|
|||||||
{
|
{
|
||||||
throw PiPedalException("File not found.");
|
throw PiPedalException("File not found.");
|
||||||
}
|
}
|
||||||
AudioDirectoryInfo::Ptr audioDirectory = AudioDirectoryInfo::Create(path.parent_path());
|
AudioDirectoryInfo::Ptr audioDirectory = CreateDirectoryInfo(path.parent_path());
|
||||||
|
|
||||||
auto files = audioDirectory->GetFiles();
|
auto files = audioDirectory->GetFiles();
|
||||||
for (const auto &file : files)
|
for (const auto &file : files)
|
||||||
{
|
{
|
||||||
@@ -516,7 +535,26 @@ public:
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
fs::path path = request_uri.query("path");
|
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<TemporaryFile> thumbnail = std::make_shared<TemporaryFile>();
|
||||||
|
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))
|
if (!fs::exists(path) || !this->model->IsInUploadsDirectory(path) || HasDotDot(path))
|
||||||
{
|
{
|
||||||
throw PiPedalException("File not found.");
|
throw PiPedalException("File not found.");
|
||||||
@@ -525,8 +563,10 @@ public:
|
|||||||
int32_t width = ConvertThumbnailSize(request_uri.query("w"));
|
int32_t width = ConvertThumbnailSize(request_uri.query("w"));
|
||||||
int32_t height = ConvertThumbnailSize(request_uri.query("h"));
|
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.
|
audioDirectory->GetFiles(); // ensure that the .index file is up to date.
|
||||||
|
|
||||||
ThumbnailTemporaryFile thumbnail;
|
ThumbnailTemporaryFile thumbnail;
|
||||||
try {
|
try {
|
||||||
thumbnail = audioDirectory->GetThumbnail(path.filename(), width, height);
|
thumbnail = audioDirectory->GetThumbnail(path.filename(), width, height);
|
||||||
@@ -535,7 +575,8 @@ public:
|
|||||||
}
|
}
|
||||||
res.set(HttpField::content_type, thumbnail.GetMimeType());
|
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())));
|
res.set(HttpField::content_length, std::to_string(fs::file_size(thumbnail.Path())));
|
||||||
|
|
||||||
std::filesystem::path t = thumbnail.Path();
|
std::filesystem::path t = thumbnail.Path();
|
||||||
@@ -544,9 +585,7 @@ public:
|
|||||||
}
|
}
|
||||||
catch (const std::exception &e)
|
catch (const std::exception &e)
|
||||||
{
|
{
|
||||||
Lv2Log::error("Error getting thumbnail: %s", e.what());
|
Lv2Log::error("Error getting thumbnail: %s - (%s)", request_uri.str().c_str(), e.what());
|
||||||
res.set(HttpField::location, "/img/missing_thumbnail.jpg");
|
|
||||||
// response = 307
|
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -563,7 +602,7 @@ public:
|
|||||||
throw PiPedalException("File not found.");
|
throw PiPedalException("File not found.");
|
||||||
}
|
}
|
||||||
auto directoryPath = path.parent_path();
|
auto directoryPath = path.parent_path();
|
||||||
auto directoryInfo = AudioDirectoryInfo::Create(directoryPath);
|
auto directoryInfo = CreateDirectoryInfo(directoryPath);
|
||||||
std::string result = directoryInfo->GetNextAudioFile(path.filename());
|
std::string result = directoryInfo->GetNextAudioFile(path.filename());
|
||||||
if (!result.empty())
|
if (!result.empty())
|
||||||
{
|
{
|
||||||
@@ -595,7 +634,7 @@ public:
|
|||||||
throw PiPedalException("File not found.");
|
throw PiPedalException("File not found.");
|
||||||
}
|
}
|
||||||
auto directoryPath = path.parent_path();
|
auto directoryPath = path.parent_path();
|
||||||
auto directoryInfo = AudioDirectoryInfo::Create(directoryPath);
|
auto directoryInfo = CreateDirectoryInfo(directoryPath);
|
||||||
std::string result = directoryInfo->GetPreviousAudioFile(path.filename());
|
std::string result = directoryInfo->GetPreviousAudioFile(path.filename());
|
||||||
if (!result.empty())
|
if (!result.empty())
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
Sort order.
|
Serialize sort order.
|
||||||
Do we have a better plugin category than "Utility"
|
|
||||||
.index files.
|
|
||||||
- pipewire aux in?
|
- pipewire aux in?
|
||||||
|
|
||||||
libsqlite3-dev install
|
libsqlite3-dev install
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
* SOFTWARE.
|
* SOFTWARE.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { pathConcat, pathParentDirectory } from "./FileUtils";
|
||||||
import { PiPedalModel } from "./PiPedalModel";
|
import { PiPedalModel } from "./PiPedalModel";
|
||||||
|
|
||||||
export class ThumbnailType {
|
export class ThumbnailType {
|
||||||
@@ -39,6 +40,8 @@ export default class AudioFileMetadata {
|
|||||||
this.title = o.title;
|
this.title = o.title;
|
||||||
this.track = o.track;
|
this.track = o.track;
|
||||||
this.album = o.album;
|
this.album = o.album;
|
||||||
|
this.artist = o.artist;
|
||||||
|
this.albumArtist = o.albumArtist;
|
||||||
this.duration = o.duration;
|
this.duration = o.duration;
|
||||||
this.thumbnailType = o.thumbnailType;
|
this.thumbnailType = o.thumbnailType;
|
||||||
this.thumbnailFile = o.thumbnailFile;
|
this.thumbnailFile = o.thumbnailFile;
|
||||||
@@ -47,16 +50,20 @@ export default class AudioFileMetadata {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
fileName: string = "";
|
fileName: string = "";
|
||||||
lastModified: number = 0;
|
lastModified: number = 0;
|
||||||
title: string = "";
|
title: string = "";
|
||||||
track: number = 0;
|
track: number = 0;
|
||||||
album: string = "";
|
album: string = "";
|
||||||
|
artist: string = "";
|
||||||
|
albumArtist: string = "";
|
||||||
duration: number = 0;
|
duration: number = 0;
|
||||||
thumbnailType: ThumbnailType = new ThumbnailType();
|
thumbnailType: ThumbnailType = new ThumbnailType();
|
||||||
thumbnailFile = "";
|
thumbnailFile = "";
|
||||||
thumbnailLastModified = 0;
|
thumbnailLastModified = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export function getAlbumArtUri(model: PiPedalModel, metadata: AudioFileMetadata | undefined, path: string): string {
|
export function getAlbumArtUri(model: PiPedalModel, metadata: AudioFileMetadata | undefined, path: string): string {
|
||||||
let coverArtUri: string;
|
let coverArtUri: string;
|
||||||
if (!metadata) {
|
if (!metadata) {
|
||||||
@@ -65,10 +72,21 @@ export function getAlbumArtUri(model: PiPedalModel, metadata: AudioFileMetadata
|
|||||||
if (metadata.thumbnailType === ThumbnailType.None) {
|
if (metadata.thumbnailType === ThumbnailType.None) {
|
||||||
return "/img/missing_thumbnail.jpg";
|
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 {
|
} else {
|
||||||
|
// for embeed and unknown
|
||||||
coverArtUri = model.varServerUrl + "Thumbnail"
|
coverArtUri = model.varServerUrl + "Thumbnail"
|
||||||
+ "?path=" + encodeURIComponent(path)
|
+ "?path=" + encodeURIComponent(path)
|
||||||
+ "&t=" + metadata.thumbnailLastModified
|
+ "&t=" + metadata.lastModified
|
||||||
+ "&w=240&h=240"
|
+ "&w=240&h=240"
|
||||||
;
|
;
|
||||||
return coverArtUri;
|
return coverArtUri;
|
||||||
|
|||||||
@@ -35,6 +35,17 @@ export interface DraggableButtonBaseProps extends ButtonBaseProps {
|
|||||||
interface Point {
|
interface Point {
|
||||||
x: number;
|
x: number;
|
||||||
y: 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 {
|
function screenToClient(element: HTMLElement, point: Point): Point {
|
||||||
@@ -81,6 +92,12 @@ export default function DraggableButtonBase(props: DraggableButtonBaseProps) {
|
|||||||
<ButtonBase
|
<ButtonBase
|
||||||
{...rest}
|
{...rest}
|
||||||
onPointerDown={(e) => {
|
onPointerDown={(e) => {
|
||||||
|
if (!isValidPointer(e)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pointerId !== null) {
|
||||||
|
return; //tracking another pointer already.
|
||||||
|
}
|
||||||
e.currentTarget.setPointerCapture(e.pointerId);
|
e.currentTarget.setPointerCapture(e.pointerId);
|
||||||
setPointerId(e.pointerId);
|
setPointerId(e.pointerId);
|
||||||
setPointerDownPoint(screenToClient(e.currentTarget,{ x: e.screenX, y: e.screenY }));
|
setPointerDownPoint(screenToClient(e.currentTarget,{ x: e.screenX, y: e.screenY }));
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ import Toolbar from '@mui/material/Toolbar';
|
|||||||
import WithStyles from './WithStyles';
|
import WithStyles from './WithStyles';
|
||||||
import { withStyles } from "tss-react/mui";
|
import { withStyles } from "tss-react/mui";
|
||||||
import CircularProgress from '@mui/material/CircularProgress';
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
|
import { pathConcat,pathParentDirectory,pathFileName,pathFileNameOnly, pathExtension } from './FileUtils'; './FileUtils';
|
||||||
|
|
||||||
|
|
||||||
import ResizeResponsiveComponent from './ResizeResponsiveComponent';
|
import ResizeResponsiveComponent from './ResizeResponsiveComponent';
|
||||||
@@ -139,6 +139,7 @@ export interface FilePropertyDialogState {
|
|||||||
fileResult: FileRequestResult;
|
fileResult: FileRequestResult;
|
||||||
navDirectory: string;
|
navDirectory: string;
|
||||||
windowWidth: number;
|
windowWidth: number;
|
||||||
|
windowHeight: number;
|
||||||
currentDirectory: string;
|
currentDirectory: string;
|
||||||
isProtectedDirectory: boolean;
|
isProtectedDirectory: boolean;
|
||||||
columns: number;
|
columns: number;
|
||||||
@@ -158,60 +159,6 @@ class DragState {
|
|||||||
from: number = 0;
|
from: number = 0;
|
||||||
to: 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(
|
export default withStyles(
|
||||||
class FilePropertyDialog extends ResizeResponsiveComponent<FilePropertyDialogProps, FilePropertyDialogState> {
|
class FilePropertyDialog extends ResizeResponsiveComponent<FilePropertyDialogProps, FilePropertyDialogState> {
|
||||||
@@ -242,6 +189,7 @@ export default withStyles(
|
|||||||
selectedFile: selectedFile,
|
selectedFile: selectedFile,
|
||||||
selectedFileProtected: true,
|
selectedFileProtected: true,
|
||||||
windowWidth: this.windowSize.width,
|
windowWidth: this.windowSize.width,
|
||||||
|
windowHeight: this.windowSize.height,
|
||||||
selectedFileIsDirectory: false,
|
selectedFileIsDirectory: false,
|
||||||
navDirectory: this.getNavDirectoryFromFile(selectedFile, props.fileProperty),
|
navDirectory: this.getNavDirectoryFromFile(selectedFile, props.fileProperty),
|
||||||
currentDirectory: "",
|
currentDirectory: "",
|
||||||
@@ -370,7 +318,7 @@ export default withStyles(
|
|||||||
element.style.zIndex = "1000";
|
element.style.zIndex = "1000";
|
||||||
element.style.top = "5px";
|
element.style.top = "5px";
|
||||||
element.style.left = "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) {
|
if (this.lastDivRef) {
|
||||||
this.longPressStartPoint = screenToClient(this.lastDivRef, { x: e.screenX, y: e.screenY });
|
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(ixTo, 0, newFileResult.files[ixFrom]);
|
||||||
newFileResult.files.splice(ixFrom + 1, 1);
|
newFileResult.files.splice(ixFrom + 1, 1);
|
||||||
} else {
|
} else {
|
||||||
// move down.
|
// move down.
|
||||||
newFileResult.files.splice(ixTo+1,0,newFileResult.files[ixFrom]);
|
newFileResult.files.splice(ixTo + 1, 0, newFileResult.files[ixFrom]);
|
||||||
newFileResult.files.splice(ixFrom, 1);
|
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.
|
// Now send the reorder request to the server.
|
||||||
this.model.reorderAudioFiles(
|
this.model.moveAudioFile(
|
||||||
this.state.currentDirectory, from, to);
|
this.state.currentDirectory, from, to);
|
||||||
}
|
}
|
||||||
handleLongPressEnd(e: React.PointerEvent<HTMLButtonElement>) {
|
handleLongPressEnd(e: React.PointerEvent<HTMLButtonElement>) {
|
||||||
@@ -499,7 +447,8 @@ export default withStyles(
|
|||||||
|
|
||||||
this.setState({
|
this.setState({
|
||||||
fullScreen: this.getFullScreen(),
|
fullScreen: this.getFullScreen(),
|
||||||
windowWidth: width
|
windowWidth: width,
|
||||||
|
windowHeight: height
|
||||||
})
|
})
|
||||||
if (this.lastDivRef !== null) {
|
if (this.lastDivRef !== null) {
|
||||||
this.onMeasureRef(this.lastDivRef);
|
this.onMeasureRef(this.lastDivRef);
|
||||||
@@ -679,9 +628,32 @@ export default withStyles(
|
|||||||
this.requestFiles(relativeDirectory);
|
this.requestFiles(relativeDirectory);
|
||||||
this.setState({ navDirectory: 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 {
|
getTrackTitle(fileEntry: FileEntry): string {
|
||||||
if (!fileEntry.metadata) {
|
if (!fileEntry.metadata) {
|
||||||
return fileEntry.displayName;
|
return pathFileName(fileEntry.pathname);
|
||||||
|
}
|
||||||
|
if (fileEntry.metadata.title === "") {
|
||||||
|
return pathFileName(fileEntry.pathname);
|
||||||
}
|
}
|
||||||
let metadata = fileEntry.metadata;
|
let metadata = fileEntry.metadata;
|
||||||
let trackDisplay = "";
|
let trackDisplay = "";
|
||||||
@@ -692,7 +664,11 @@ export default withStyles(
|
|||||||
trackDisplay = metadata.track.toString() + ". ";
|
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 {
|
getTrackThumbnail(fileEntry: FileEntry): string {
|
||||||
return getAlbumArtUri(this.model, fileEntry.metadata, fileEntry.pathname);
|
return getAlbumArtUri(this.model, fileEntry.metadata, fileEntry.pathname);
|
||||||
@@ -793,19 +769,29 @@ export default withStyles(
|
|||||||
}
|
}
|
||||||
return result;
|
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 === "") {
|
if (fileEntry.pathname === "") {
|
||||||
return (<InsertDriveFileOutlinedIcon style={{ flex: "0 0 auto", opacity: 0.5, marginRight: 8, marginLeft: 8 }} />);
|
return (<InsertDriveFileOutlinedIcon style={style} />);
|
||||||
}
|
}
|
||||||
if (fileEntry.isDirectory) {
|
if (fileEntry.isDirectory) {
|
||||||
return (
|
return (
|
||||||
<FolderIcon style={{ flex: "0 0 auto", opacity: 0.7, marginRight: 8, marginLeft: 8 }} />
|
<FolderIcon style={style} />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (isAudioFile(fileEntry.pathname)) {
|
if (isAudioFile(fileEntry.pathname)) {
|
||||||
return (<AudioFileIcon style={{ flex: "0 0 auto", opacity: 0.7, marginRight: 8, marginLeft: 8 }} />);
|
return (<AudioFileIcon style={style} />);
|
||||||
}
|
}
|
||||||
return (<InsertDriveFileIcon style={{ flex: "0 0 auto", opacity: 0.7, marginRight: 8, marginLeft: 8 }} />);
|
return (<InsertDriveFileIcon style={style} />);
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
@@ -827,6 +813,7 @@ export default withStyles(
|
|||||||
let canReorder = isTracksDirectory;
|
let canReorder = isTracksDirectory;
|
||||||
let needsDivider = canMove || canRename || canReorder;
|
let needsDivider = canMove || canRename || canReorder;
|
||||||
let trackPosition = 0;
|
let trackPosition = 0;
|
||||||
|
let compactVertical = this.state.windowHeight < 700;
|
||||||
|
|
||||||
return this.props.open &&
|
return this.props.open &&
|
||||||
(
|
(
|
||||||
@@ -1031,7 +1018,7 @@ export default withStyles(
|
|||||||
}
|
}
|
||||||
|
|
||||||
style={{
|
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",
|
position: "relative",
|
||||||
top: dragOffset + "px",
|
top: dragOffset + "px",
|
||||||
}}
|
}}
|
||||||
@@ -1054,39 +1041,63 @@ export default withStyles(
|
|||||||
>
|
>
|
||||||
<div ref={scrollRef} style={{ position: "absolute", background: selectBg, width: "100%", height: "100%", borderRadius: 4 }} />
|
<div ref={scrollRef} style={{ position: "absolute", background: selectBg, width: "100%", height: "100%", borderRadius: 4 }} />
|
||||||
{value.metadata ?
|
{value.metadata ?
|
||||||
(
|
(!compactVertical ?
|
||||||
<div style={{
|
(
|
||||||
display: "flex", flexFlow: "row nowrap", textOverflow: "ellipsis",
|
|
||||||
justifyContent: "start", alignItems: "center", width: "100%", height: "100%"
|
|
||||||
}}>
|
|
||||||
<img
|
|
||||||
onDragStart={(e) => { e.preventDefault(); }}
|
|
||||||
src={this.getTrackThumbnail(value)}
|
|
||||||
style={{ width: 48, height: 48, margin: 8, borderRadius: 4 }} />
|
|
||||||
<div style={{
|
<div style={{
|
||||||
flex: "1 1 1px", display: "flex", flexFlow: "column nowrap",
|
display: "flex", flexFlow: "row nowrap", textOverflow: "ellipsis",
|
||||||
marginLeft: 8,
|
justifyContent: "start", alignItems: "center", width: "100%", height: "100%"
|
||||||
textOverflow: "ellipsis", justifyContent: "center", alignItems: "stretch"
|
|
||||||
}}>
|
}}>
|
||||||
<Typography noWrap
|
<img
|
||||||
className={classes.secondaryText}
|
onDragStart={(e) => { e.preventDefault(); }}
|
||||||
variant="body2"
|
src={this.getTrackThumbnail(value)}
|
||||||
style={{ flex: "0 0 auto", textOverflow: "ellipsis", textAlign: "left",marginBottom: 4 }}>
|
style={{ width: 48, height: 48, margin: 8, borderRadius: 4 }} />
|
||||||
{this.getTrackTitle(value)}
|
<div style={{
|
||||||
</Typography>
|
flex: "1 1 1px", display: "flex", flexFlow: "column nowrap",
|
||||||
<Typography noWrap className={classes.secondaryText}
|
marginLeft: 8,
|
||||||
variant="body2"
|
textOverflow: "ellipsis", justifyContent: "center", alignItems: "stretch"
|
||||||
color="textSecondary"
|
}}>
|
||||||
style={{ flex: "0 0 auto", textOverflow: "ellipsis", textAlign: "left", fontSize: "0.95em" }}>
|
<Typography noWrap
|
||||||
{value.metadata?.album ?? "#error"}
|
className={classes.secondaryText}
|
||||||
|
variant="body2"
|
||||||
|
style={{ flex: "0 0 auto", textOverflow: "ellipsis", textAlign: "left", marginBottom: 4 }}>
|
||||||
|
{this.getTrackTitle(value)}
|
||||||
|
</Typography>
|
||||||
|
<Typography noWrap className={classes.secondaryText}
|
||||||
|
variant="body2"
|
||||||
|
color="textSecondary"
|
||||||
|
style={{ flex: "0 0 auto", textOverflow: "ellipsis", textAlign: "left", fontSize: "0.95em" }}>
|
||||||
|
{this.getAlbumTitle(value)}
|
||||||
|
|
||||||
</Typography>
|
</Typography>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
|
<div style={{
|
||||||
|
display: "flex", flexFlow: "row nowrap", textOverflow: "ellipsis",
|
||||||
|
justifyContent: "start", alignItems: "center", width: "100%", height: "100%"
|
||||||
|
}}>
|
||||||
|
<img
|
||||||
|
onDragStart={(e) => { e.preventDefault(); }}
|
||||||
|
src={this.getTrackThumbnail(value)}
|
||||||
|
style={{ width: 24, height: 24, margin: 8, borderRadius: 4 }} />
|
||||||
|
<div style={{
|
||||||
|
flex: "1 1 1px", display: "flex", flexFlow: "column nowrap",
|
||||||
|
marginLeft: 8,
|
||||||
|
textOverflow: "ellipsis", justifyContent: "center", alignItems: "stretch"
|
||||||
|
}}>
|
||||||
|
<Typography noWrap
|
||||||
|
className={classes.secondaryText}
|
||||||
|
variant="body2"
|
||||||
|
style={{ flex: "0 0 auto", textOverflow: "ellipsis", textAlign: "left", marginBottom: 4 }}>
|
||||||
|
{this.getCompactTrackTitle(value)}
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: "flex", flexFlow: "row nowrap", textOverflow: "ellipsis", justifyContent: "start", alignItems: "center", width: "100%", height: "100%" }}>
|
<div style={{ display: "flex", flexFlow: "row nowrap", textOverflow: "ellipsis", justifyContent: "start", alignItems: "center", width: "100%", height: "100%" }}>
|
||||||
{this.getIcon(value)}
|
{this.getIcon(value, this.isTracksDirectory() && !compactVertical)}
|
||||||
<Typography noWrap className={classes.secondaryText} variant="body2" style={{ flex: "1 1 auto", textAlign: "left" }}>{displayValue}</Typography>
|
<Typography noWrap className={classes.secondaryText} variant="body2" style={{ flex: "1 1 auto", textAlign: "left" }}>{displayValue}</Typography>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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<HTMLTextAreaElement | HTMLInputElement>, 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<PreviewArguments | null>(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 (
|
||||||
|
<Slider
|
||||||
|
{...extra}
|
||||||
|
style={style}
|
||||||
|
step={0.0001}
|
||||||
|
value={previewArguments ? previewArguments.newValue : props.value}
|
||||||
|
onChange={(event, newValue, thumb) => {
|
||||||
|
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 (
|
||||||
|
<TextField
|
||||||
|
{...extra}
|
||||||
|
variant="standard"
|
||||||
|
spellCheck="false"
|
||||||
|
error={error}
|
||||||
|
value={text}
|
||||||
|
onBlur={() => {
|
||||||
|
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<number | null>(null);
|
||||||
|
const [previewLoopStart, setPreviewLoopStart] = React.useState<number | null>(null);
|
||||||
|
const [previewLoopEnd, setPreviewLoopEnd] = React.useState<number | null>(null);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogEx
|
||||||
|
tag="LoopDialog"
|
||||||
|
onClose={props.onClose}
|
||||||
|
fullScreen={false}
|
||||||
|
|
||||||
|
maxWidth="xl"
|
||||||
|
open={props.isOpen}
|
||||||
|
onEnterKey={() => {
|
||||||
|
}}
|
||||||
|
|
||||||
|
sx={{
|
||||||
|
"& .MuiDialog-container": {
|
||||||
|
"& .MuiPaper-root": {
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: "500px", // Set your width here
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTitle style={{ paddingTop: 0 }}>
|
||||||
|
<Toolbar style={{ padding: 0 }}>
|
||||||
|
<IconButton
|
||||||
|
edge="start"
|
||||||
|
color="inherit"
|
||||||
|
aria-label="back"
|
||||||
|
style={{ opacity: 0.6 }}
|
||||||
|
onClick={() => { props.onClose(); }}
|
||||||
|
>
|
||||||
|
<ArrowBackIcon style={{ width: 24, height: 24 }} />
|
||||||
|
</IconButton>
|
||||||
|
<Typography noWrap component="div" sx={{ flexGrow: 1 }}>
|
||||||
|
Loop
|
||||||
|
</Typography>
|
||||||
|
<div style={{ flexGrow: 1 }} />
|
||||||
|
|
||||||
|
<TimebaseSelectorDialog
|
||||||
|
|
||||||
|
onTimebaseChange={(timebase) => {
|
||||||
|
props.onTimebaseChange(timebase); // Handle timebase change if needed
|
||||||
|
}}
|
||||||
|
timebase={props.timebase} // Pass the current timebase if needed
|
||||||
|
/>
|
||||||
|
</Toolbar>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent style={{}}>
|
||||||
|
<div style={{
|
||||||
|
position: "relative",
|
||||||
|
display: "flex", justifyContent: "stretch", flexFlow: "column nowrap", marginLeft: 32, marginRight: 32,
|
||||||
|
marginBottom: 16
|
||||||
|
|
||||||
|
}}>
|
||||||
|
<Typography display="block" variant="body1" gutterBottom style={{}}>
|
||||||
|
Start
|
||||||
|
</Typography>
|
||||||
|
<SliderWithPreview
|
||||||
|
style={{
|
||||||
|
width: "100%"
|
||||||
|
}}
|
||||||
|
value={props.start}
|
||||||
|
min={0}
|
||||||
|
max={props.duration}
|
||||||
|
|
||||||
|
onChange={(e, v, thumb) => {
|
||||||
|
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)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TimeEdit variant="standard" spellCheck="false"
|
||||||
|
value={previewStartValue ? previewStartValue : props.start}
|
||||||
|
max={props.duration}
|
||||||
|
timebase={props.timebase}
|
||||||
|
sampleRate={props.sampleRate}
|
||||||
|
|
||||||
|
|
||||||
|
onBlur={() => {
|
||||||
|
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" }} />
|
||||||
|
<FormGroup style={{ alignSelf: "start", marginTop: 8, marginBottom: 8, marginLeft: 0 }}>
|
||||||
|
<FormControlLabel
|
||||||
|
labelPlacement="start"
|
||||||
|
style={{ marginLeft: 0 }}
|
||||||
|
control={<Checkbox checked={props.loopEnable}
|
||||||
|
onChange={(e, checked) => {
|
||||||
|
props.onSetLoop(props.start, !props.loopEnable, props.loopStart, props.loopEnd);
|
||||||
|
}}
|
||||||
|
|
||||||
|
/>} label="Enable loop" />
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<SliderWithPreview
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
opacity: props.loopEnable ? 1 : 0.4,
|
||||||
|
}}
|
||||||
|
disabled={!props.loopEnable}
|
||||||
|
value={
|
||||||
|
previewLoopStart != null ?
|
||||||
|
[previewLoopStart as number, previewLoopEnd as number]
|
||||||
|
: [props.loopStart, props.loopEnd]}
|
||||||
|
min={0}
|
||||||
|
max={props.duration}
|
||||||
|
onChange={(e, v, thumb) => {
|
||||||
|
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]);
|
||||||
|
}}
|
||||||
|
|
||||||
|
/>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-evenly", columnGap: 8 }}>
|
||||||
|
<TimeEdit variant="standard" spellCheck="false"
|
||||||
|
value={previewLoopStart ? previewLoopStart : props.loopStart}
|
||||||
|
disabled={!props.loopEnable}
|
||||||
|
max={props.duration}
|
||||||
|
timebase={props.timebase}
|
||||||
|
sampleRate={props.sampleRate}
|
||||||
|
|
||||||
|
style={{
|
||||||
|
maxWidth: 120, textAlign: "center", opacity: props.loopEnable ? 1 : 0.4,
|
||||||
|
flex: "1 1 auto"
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TimeEdit variant="standard" spellCheck="false"
|
||||||
|
max={props.duration}
|
||||||
|
|
||||||
|
value={previewLoopEnd ? previewLoopEnd : props.loopEnd}
|
||||||
|
sampleRate={props.sampleRate}
|
||||||
|
|
||||||
|
disabled={!props.loopEnable}
|
||||||
|
|
||||||
|
timebase={props.timebase}
|
||||||
|
|
||||||
|
style={{
|
||||||
|
maxWidth: 120, textAlign: "center", opacity: props.loopEnable ? 1 : 0.4,
|
||||||
|
flex: "1 1 auto"
|
||||||
|
}}
|
||||||
|
|
||||||
|
onBlur={() => {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</DialogContent>
|
||||||
|
</DialogEx>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -919,6 +919,7 @@ export class PiPedalModel //implements PiPedalModel
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async onSocketReconnected(): Promise<void> {
|
async onSocketReconnected(): Promise<void> {
|
||||||
this.cancelOnNetworkChanging();
|
this.cancelOnNetworkChanging();
|
||||||
this.cancelAndroidReconnectTimer();
|
this.cancelAndroidReconnectTimer();
|
||||||
@@ -977,6 +978,7 @@ export class PiPedalModel //implements PiPedalModel
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async getAudioFileMetadata(filePath: string): Promise<AudioFileMetadata> {
|
async getAudioFileMetadata(filePath: string): Promise<AudioFileMetadata> {
|
||||||
try {
|
try {
|
||||||
let url =
|
let url =
|
||||||
@@ -1822,6 +1824,27 @@ export class PiPedalModel //implements PiPedalModel
|
|||||||
.request("setOnboarding", value);
|
.request("setOnboarding", value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
moveAudioFile(
|
||||||
|
path: string,
|
||||||
|
from: number,
|
||||||
|
to: number
|
||||||
|
): Promise<boolean> {
|
||||||
|
return new Promise<boolean>(
|
||||||
|
(accept, reject) => {
|
||||||
|
this.webSocket?.request<boolean>("moveAudioFile", {
|
||||||
|
path: path,
|
||||||
|
from: from,
|
||||||
|
to: to
|
||||||
|
}).then(() => {
|
||||||
|
accept(true);
|
||||||
|
}).catch((e) => {
|
||||||
|
this.setError(getErrorMessage(e));
|
||||||
|
accept(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
saveCurrentPresetAs(newName: string, saveAfterInstanceId = -1): Promise<number> {
|
saveCurrentPresetAs(newName: string, saveAfterInstanceId = -1): Promise<number> {
|
||||||
// default behaviour is to save after the currently selected preset.
|
// 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;
|
let instance: PiPedalModel | undefined = undefined;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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<HTMLInputElement>) => {
|
||||||
|
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 (
|
||||||
|
<TextField
|
||||||
|
{...extras}
|
||||||
|
style={style}
|
||||||
|
variant="standard"
|
||||||
|
type="number"
|
||||||
|
value={text}
|
||||||
|
onChange={handleChange}
|
||||||
|
inputProps={{
|
||||||
|
min: props.min,
|
||||||
|
max: props.max,
|
||||||
|
step: props.step,
|
||||||
|
style: { textAlign: 'center' }
|
||||||
|
}}
|
||||||
|
error={error}
|
||||||
|
onFocus={(e) => {
|
||||||
|
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>({ ...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 (
|
||||||
|
<>
|
||||||
|
<Button variant="dialogSecondary" onClick={handleOpen}
|
||||||
|
style={{ textTransform: 'none' }}
|
||||||
|
>
|
||||||
|
Units: {getTimebaseTypeLabel(timebase)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<DialogEx tag="timebasesettings"
|
||||||
|
open={open} onClose={handleClose} maxWidth="sm"
|
||||||
|
onEnterKey={() => { }}
|
||||||
|
>
|
||||||
|
<DialogTitle style={{ paddingTop: 0 }}>
|
||||||
|
<Toolbar style={{ padding: 0 }}>
|
||||||
|
<IconButton
|
||||||
|
edge="start"
|
||||||
|
color="inherit"
|
||||||
|
aria-label="back"
|
||||||
|
style={{ opacity: 0.6 }}
|
||||||
|
onClick={() => { handleClose(); }}
|
||||||
|
>
|
||||||
|
<ArrowBackIcon style={{ width: 24, height: 24 }} />
|
||||||
|
</IconButton>
|
||||||
|
<Typography noWrap component="div" sx={{ flexGrow: 1 }}>
|
||||||
|
Timebase Settings
|
||||||
|
</Typography>
|
||||||
|
<div style={{ flexGrow: 1 }} />
|
||||||
|
|
||||||
|
</Toolbar>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Box sx={{
|
||||||
|
paddingLeft: 0, paddingRight: 0,
|
||||||
|
paddingTop: 0, paddingBottom: 1
|
||||||
|
}}>
|
||||||
|
<div style={{ display: "flex", flexFlow: "column nowrap", alignItems: "start" }}>
|
||||||
|
<Typography variant="caption">
|
||||||
|
Units
|
||||||
|
</Typography>
|
||||||
|
<Select variant="standard"
|
||||||
|
style={{ width: 180 }}
|
||||||
|
value={editingTimebase.units}
|
||||||
|
label="Timebase"
|
||||||
|
onChange={handleTimebaseTypeChange}
|
||||||
|
>
|
||||||
|
<MenuItem value={TimebaseUnits.Seconds}>Seconds</MenuItem>
|
||||||
|
<MenuItem value={TimebaseUnits.Samples}>Samples</MenuItem>
|
||||||
|
<MenuItem value={TimebaseUnits.Beats}>Beats</MenuItem>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<div style={{ opacity: enableBeatControls ? 1 : 0.4, }}
|
||||||
|
>
|
||||||
|
<Typography display="block" variant="caption" style={{ paddingTop: 24 }}>
|
||||||
|
Tempo (BPM)
|
||||||
|
</Typography>
|
||||||
|
<NumericEdit
|
||||||
|
parse={(value: string) => parseFloat(value)}
|
||||||
|
min={10}
|
||||||
|
max={400}
|
||||||
|
step={1}
|
||||||
|
onValueChange={handleTempoChange}
|
||||||
|
value={editingTimebase.tempo}
|
||||||
|
disabled={!enableBeatControls}
|
||||||
|
|
||||||
|
variant="standard"
|
||||||
|
type="number"
|
||||||
|
style={{ width: 120, }}
|
||||||
|
/>
|
||||||
|
<Typography display="block" variant="caption" style={{ paddingTop: 24 }}>
|
||||||
|
Time Signature
|
||||||
|
</Typography>
|
||||||
|
<div style={{
|
||||||
|
display: "flex", flexFlow: "row nowrap",
|
||||||
|
alignItems: "center", justifyContent: "start"
|
||||||
|
}}>
|
||||||
|
|
||||||
|
<NumericEdit
|
||||||
|
parse={(value: string) => 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}
|
||||||
|
/>
|
||||||
|
<Typography variant="body2"> / </Typography>
|
||||||
|
<Select variant="standard"
|
||||||
|
style={{ width: 80 }}
|
||||||
|
value={editingTimebase.timeSignature.denominator}
|
||||||
|
disabled={!enableBeatControls}
|
||||||
|
|
||||||
|
label="Timebase"
|
||||||
|
sx={{
|
||||||
|
'& .MuiSelect-select': {
|
||||||
|
textAlign: 'center'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onChange={(e) => {
|
||||||
|
handleTimeSignatureChange('denominator', parseInt(e.target.value.toString()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MenuItem value={2} sx={{ justifyContent: 'center' }}>2</MenuItem>
|
||||||
|
<MenuItem value={4} sx={{ justifyContent: 'center' }}>4</MenuItem>
|
||||||
|
<MenuItem value={8} sx={{ justifyContent: 'center' }}>8</MenuItem>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Box>
|
||||||
|
</DialogContent >
|
||||||
|
</DialogEx >
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -23,17 +23,19 @@
|
|||||||
|
|
||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { styled } from '@mui/material/styles';
|
import { styled } from '@mui/material/styles';
|
||||||
|
import LoopDialog from './LoopDialog';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
import Slider from '@mui/material/Slider';
|
import Slider from '@mui/material/Slider';
|
||||||
import IconButton from '@mui/material/IconButton';
|
import IconButton from '@mui/material/IconButton';
|
||||||
import Pause from '@mui/icons-material/Pause';
|
import Pause from '@mui/icons-material/Pause';
|
||||||
import PlayArrow from '@mui/icons-material/PlayArrow';
|
import PlayArrow from '@mui/icons-material/PlayArrow';
|
||||||
import FastForward from '@mui/icons-material/FastForward';
|
import FastForward from '@mui/icons-material/FastForward';
|
||||||
import FastRewind from '@mui/icons-material/FastRewind';
|
import FastRewind from '@mui/icons-material/FastRewind';
|
||||||
import { PiPedalModelFactory } from './PiPedalModel';
|
import { PiPedalModelFactory, State } from './PiPedalModel';
|
||||||
import ButtonTooltip from './ButtonTooltip';
|
import ButtonTooltip from './ButtonTooltip';
|
||||||
import { pathFileNameOnly } from './FilePropertyDialog'
|
import { pathFileNameOnly } from './FileUtils';
|
||||||
import ButtonBase from '@mui/material/ButtonBase';
|
import ButtonBase from '@mui/material/ButtonBase';
|
||||||
import FilePropertyDialog from './FilePropertyDialog';
|
import FilePropertyDialog from './FilePropertyDialog';
|
||||||
import JsonAtom from './JsonAtom';
|
import JsonAtom from './JsonAtom';
|
||||||
@@ -41,6 +43,8 @@ import { UiFileProperty } from './Lv2Plugin';
|
|||||||
import { Divider } from '@mui/material';
|
import { Divider } from '@mui/material';
|
||||||
import useWindowSize from './UseWindowSize';
|
import useWindowSize from './UseWindowSize';
|
||||||
import { getAlbumArtUri } from './AudioFileMetadata';
|
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"
|
let Player__seek = "http://two-play.com/plugins/toob-player#seek"
|
||||||
const AUDIO_FILE_PROPERTY_URI = "http://two-play.com/plugins/toob-player#audioFile";
|
const AUDIO_FILE_PROPERTY_URI = "http://two-play.com/plugins/toob-player#audioFile";
|
||||||
@@ -55,6 +59,7 @@ class PluginState {
|
|||||||
static Error = 6;
|
static Error = 6;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const useWallpaper = false;
|
||||||
const WallPaper = styled('div')({
|
const WallPaper = styled('div')({
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
width: '100%',
|
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 {
|
interface WidgetProps {
|
||||||
noBorders: boolean;
|
noBorders: boolean;
|
||||||
@@ -104,9 +117,9 @@ const WidgetBorders = styled('div')(({ theme }) => ({
|
|||||||
position: 'relative',
|
position: 'relative',
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
backgroundColor: 'rgba(255,255,255,0.4)',
|
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', {
|
...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,
|
zIndex: 1,
|
||||||
backgroundColor: 'rgba(255,255,255,0.4)',
|
backgroundColor: 'rgba(255,255,255,0.4)',
|
||||||
...theme.applyStyles('dark', {
|
...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) {
|
function Widget(props: WidgetProps) {
|
||||||
|
// if (!useWallpaper) {
|
||||||
|
// return(<WidgetNoWallpaper>{props.children} </WidgetNoWallpaper>);
|
||||||
|
// }
|
||||||
if (props.noBorders) {
|
if (props.noBorders) {
|
||||||
return (<WidgetNoBorders>{props.children} </WidgetNoBorders>);
|
return (<WidgetNoBorders>{props.children} </WidgetNoBorders>);
|
||||||
} else {
|
} else {
|
||||||
@@ -163,9 +190,17 @@ export interface ToobPlayerControlProps {
|
|||||||
export default function ToobPlayerControl(
|
export default function ToobPlayerControl(
|
||||||
props: ToobPlayerControlProps
|
props: ToobPlayerControlProps
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
const model = PiPedalModelFactory.getInstance();
|
||||||
|
|
||||||
const defaultCoverArt = "/img/default_album.jpg";
|
const defaultCoverArt = "/img/default_album.jpg";
|
||||||
|
const [serverConnected, setServerConnected] = React.useState(model.state.get() == State.Ready);
|
||||||
const [duration, setDuration] = React.useState(0.0);
|
const [duration, setDuration] = React.useState(0.0);
|
||||||
const [position, setPosition] = 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 [dragging, setDragging] = React.useState(false);
|
||||||
const [pluginState, setPluginState] = React.useState(0.0);
|
const [pluginState, setPluginState] = React.useState(0.0);
|
||||||
const [sliderValue, setSliderValue] = 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 [audioFile, setAudioFile] = React.useState("");
|
||||||
const [title, setTitle] = React.useState("");
|
const [title, setTitle] = React.useState("");
|
||||||
const [album, setAlbum] = React.useState("");
|
const [album, setAlbum] = React.useState("");
|
||||||
|
const [artist, setArtist] = React.useState("");
|
||||||
|
const [albumArtist, setAlbumArtist] = React.useState("");
|
||||||
const [showFileDialog, setShowFileDialog] = React.useState(false);
|
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 [size] = useWindowSize();
|
||||||
const width = size.width;
|
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 useHorizontalLayout = height < HORIZONTAL_CONTROL_SCROLL_HEIGHT_BREAK && width > 573 && width > height;
|
||||||
//const useVerticalScroll = width < 573;
|
//const useVerticalScroll = width < 573;
|
||||||
const useQuadMixPanel = width < 720;
|
|
||||||
const noBorders = width < 420 || height < 720;
|
const noBorders = width < 420 || height < 720;
|
||||||
|
let useQuadMixPanel = width < 720;
|
||||||
|
if (noBorders) {
|
||||||
|
useQuadMixPanel = width < 370;
|
||||||
|
}
|
||||||
|
|
||||||
function SelectFile() {
|
function SelectFile() {
|
||||||
setShowFileDialog(true);
|
setShowFileDialog(true);
|
||||||
@@ -202,6 +248,8 @@ export default function ToobPlayerControl(
|
|||||||
if (path === "") {
|
if (path === "") {
|
||||||
setTitle("");
|
setTitle("");
|
||||||
setAlbum("");
|
setAlbum("");
|
||||||
|
setArtist("");
|
||||||
|
setAlbumArtist("");
|
||||||
setCoverArt(defaultCoverArt);
|
setCoverArt(defaultCoverArt);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -221,6 +269,8 @@ export default function ToobPlayerControl(
|
|||||||
setCoverArt(coverArtUri);
|
setCoverArt(coverArtUri);
|
||||||
setTitle(strTrack + metadata.title);
|
setTitle(strTrack + metadata.title);
|
||||||
setAlbum(metadata.album);
|
setAlbum(metadata.album);
|
||||||
|
setArtist(metadata.artist);
|
||||||
|
setAlbumArtist(metadata.albumArtist);
|
||||||
})
|
})
|
||||||
.catch((e)=>{
|
.catch((e)=>{
|
||||||
setTitle("#error" + e.message);
|
setTitle("#error" + e.message);
|
||||||
@@ -311,6 +361,10 @@ export default function ToobPlayerControl(
|
|||||||
<Box sx={{ display: 'flex', alignItems: 'center', padding: "2px" }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', padding: "2px" }}>
|
||||||
<CoverImage>
|
<CoverImage>
|
||||||
<img style={{ opacity: pluginState === PluginState.Idle ? 0.3 : 1.0 }}
|
<img style={{ opacity: pluginState === PluginState.Idle ? 0.3 : 1.0 }}
|
||||||
|
onDragStart={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
}}
|
||||||
|
alt="Cover Art"
|
||||||
src={
|
src={
|
||||||
coverArt
|
coverArt
|
||||||
}
|
}
|
||||||
@@ -324,10 +378,10 @@ export default function ToobPlayerControl(
|
|||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
<Typography variant="body1" noWrap>
|
<Typography variant="body1" noWrap>
|
||||||
{effectiveTitle}
|
{titleLine}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" noWrap sx={{}}>
|
<Typography variant="body2" noWrap sx={{}}>
|
||||||
{album}
|
{albumLine}
|
||||||
</Typography>
|
</Typography>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -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 {
|
function getUiFileProperty(uri: string): UiFileProperty {
|
||||||
let pedalboardItem = model.pedalboard.get().getItem(props.instanceId);
|
let pedalboardItem = model.pedalboard.get().getItem(props.instanceId);
|
||||||
let uiPlugin = model.getUiPlugin(pedalboardItem.uri);
|
let uiPlugin = model.getUiPlugin(pedalboardItem.uri);
|
||||||
@@ -389,7 +453,17 @@ export default function ToobPlayerControl(
|
|||||||
});
|
});
|
||||||
setSliderValue(value);
|
setSliderValue(value);
|
||||||
}
|
}
|
||||||
|
function onStateChanged(value: State) {
|
||||||
|
setServerConnected(value === State.Ready);
|
||||||
|
}
|
||||||
useEffect(() => {
|
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,
|
let durationHandle = model.monitorPort(props.instanceId, "duration", 1.0 / 15,
|
||||||
(value) => {
|
(value) => {
|
||||||
setDuration(value);
|
setDuration(value);
|
||||||
@@ -400,6 +474,27 @@ export default function ToobPlayerControl(
|
|||||||
setPosition(value);
|
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,
|
let pluginStateHandle = model.monitorPort(props.instanceId, "state", 1.0 / 1000.0,
|
||||||
(value) => {
|
(value) => {
|
||||||
setPluginState(value);
|
setPluginState(value);
|
||||||
@@ -428,17 +523,23 @@ export default function ToobPlayerControl(
|
|||||||
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
model.state.removeOnChangedHandler(onStateChanged);
|
||||||
model.unmonitorPort(durationHandle);
|
model.unmonitorPort(durationHandle);
|
||||||
model.unmonitorPort(positionHandle);
|
model.unmonitorPort(positionHandle);
|
||||||
model.unmonitorPort(pluginStateHandle);
|
model.unmonitorPort(pluginStateHandle);
|
||||||
|
// model.unmonitorPort(loopEnableHandle);
|
||||||
|
// model.unmonitorPort(startHandle);
|
||||||
|
// model.unmonitorPort(loopStartHandle);
|
||||||
|
// model.unmonitorPort(loopEndHandle);
|
||||||
model.cancelMonitorPatchProperty(filePropertyHandle);
|
model.cancelMonitorPatchProperty(filePropertyHandle);
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
[]
|
[serverConnected]
|
||||||
);
|
);
|
||||||
const effectivePosition =
|
const effectivePosition =
|
||||||
(pluginState !== PluginState.Idle) ? Math.min(position, duration) : 0.0;
|
(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
|
const paused = (pluginState === PluginState.Idle
|
||||||
|| pluginState === PluginState.CuePlayPaused
|
|| pluginState === PluginState.CuePlayPaused
|
||||||
@@ -529,12 +630,24 @@ export default function ToobPlayerControl(
|
|||||||
flex: "0 0 auto",
|
flex: "0 0 auto",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'top',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
marginTop: -4,
|
marginTop: -4,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TinyText>{formatDuration(dragging ? sliderValue : effectivePosition)}</TinyText>
|
<TinyText>{formatDuration(dragging ? sliderValue : effectivePosition)}</TinyText>
|
||||||
|
<Button title="Loop" variant="dialogSecondary"
|
||||||
|
style={{flex: "0 1 auto",width: 200,
|
||||||
|
textTransform: "none", fontSize: "0.9rem", fontWeight: 500, padding: "4px 8px"
|
||||||
|
}}
|
||||||
|
startIcon= {(<RepeatIcon />)}
|
||||||
|
onClick={() => {
|
||||||
|
setShowLoopDialog(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loopButtonText()
|
||||||
|
}
|
||||||
|
</Button>
|
||||||
<TinyText>{formatDuration(duration)}</TinyText>
|
<TinyText>{formatDuration(duration)}</TinyText>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -554,69 +667,7 @@ export default function ToobPlayerControl(
|
|||||||
{
|
{
|
||||||
FilePanel()
|
FilePanel()
|
||||||
}
|
}
|
||||||
<Slider
|
{SliderCluster()}
|
||||||
value={dragging ? sliderValue : effectivePosition}
|
|
||||||
min={0}
|
|
||||||
step={1}
|
|
||||||
max={duration}
|
|
||||||
onChange={(_, val) => {
|
|
||||||
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',
|
|
||||||
}),
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
width: "100%",
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
mt: -2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TinyText>{formatDuration(dragging ? sliderValue : effectivePosition)}</TinyText>
|
|
||||||
<TinyText>{formatDuration(duration)}</TinyText>
|
|
||||||
</Box>
|
|
||||||
{
|
{
|
||||||
ControlCluster()
|
ControlCluster()
|
||||||
}
|
}
|
||||||
@@ -683,11 +734,76 @@ export default function ToobPlayerControl(
|
|||||||
<div id="toobPlayerViewFrame" style={{
|
<div id="toobPlayerViewFrame" style={{
|
||||||
width: '100%', height: "100%", overflow: 'hidden', position: 'relative',
|
width: '100%', height: "100%", overflow: 'hidden', position: 'relative',
|
||||||
}}>
|
}}>
|
||||||
<WallPaper />
|
{useWallpaper && (<WallPaper />)}
|
||||||
{
|
{
|
||||||
useHorizontalLayout ?
|
useHorizontalLayout ?
|
||||||
HorizontalWidget() : VerticalWidget()
|
HorizontalWidget() : VerticalWidget()
|
||||||
}
|
}
|
||||||
|
{showLoopDialog && (
|
||||||
|
<LoopDialog isOpen={showLoopDialog}
|
||||||
|
sampleRate={
|
||||||
|
model.jackConfiguration.get().sampleRate == 0?
|
||||||
|
96000 :
|
||||||
|
model.jackConfiguration.get().sampleRate
|
||||||
|
}
|
||||||
|
timebase={timebase}
|
||||||
|
onTimebaseChange={(newTimebase) => {
|
||||||
|
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 && (
|
{showFileDialog && (
|
||||||
<FilePropertyDialog open={showFileDialog}
|
<FilePropertyDialog open={showFileDialog}
|
||||||
fileProperty={getUiFileProperty(AUDIO_FILE_PROPERTY_URI)}
|
fileProperty={getUiFileProperty(AUDIO_FILE_PROPERTY_URI)}
|
||||||
|
|||||||
Reference in New Issue
Block a user