diff --git a/.vscode/settings.json b/.vscode/settings.json index f7d1460..7f85420 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -135,5 +135,25 @@ "cSpell.ignoreWords": [ "nammodel" ], - "cSpell.enabled": false + "cSpell.enabled": false, + + // Disable all automatic completion suggestions - only show when Ctrl+Space is pressed + "editor.quickSuggestions": { + "other": false, + "comments": false, + "strings": false + }, + + // Disable suggestions on trigger characters (like . or :) + "editor.suggestOnTriggerCharacters": false, + + // Disable word-based suggestions from open files + "editor.wordBasedSuggestions": "off", + + // Don't accept suggestions on Enter (optional) + "editor.acceptSuggestionOnEnter": "off", + + // Disable parameter hints popup (optional) + "editor.parameterHints.enabled": false, + } \ No newline at end of file diff --git a/artifacts/missing_thumbnail.svgz b/artifacts/missing_thumbnail.svgz index ae3a106..450410b 100644 Binary files a/artifacts/missing_thumbnail.svgz and b/artifacts/missing_thumbnail.svgz differ diff --git a/src/AudioFiles.cpp b/src/AudioFiles.cpp index 0043925..b9cc1ad 100644 --- a/src/AudioFiles.cpp +++ b/src/AudioFiles.cpp @@ -43,6 +43,41 @@ namespace fs = std::filesystem; static constexpr size_t INVALID_INDEX = std::numeric_limits::max(); + +// All the varous cover art files I found on my personal music collection. +// These are used to find the cover art for a directory. Based on +// scanning a large music collection that have been tagged by various tools +// including iTunes, Windows Media Playe, and a variety of taggers. +// +// Files are listed in order of preference. + +static std::vector COVER_ART_FILES = { + "Folder.jpg", // window media player/explorer. + "Cover.jpg", // itunes. + "folder.jpg", // Linux audio players plex, kodi, Rythmbox, Amorok, Clementine. + "cover.jpg", // Linux audio players plex, kodi, Rythmbox, Amorok, Clementine. + "Artwork.jpg", // itunes. + "Front.jpg", + "front.jpg", // linux. + "AlbumArt.jpg", + "albumArt.jpg", + "Frontcover.jpg", + "AlbumArtSmall.jpg" // windows media player. +}; + +bool pipedal::isArtworkFileName(const std::string &fileName) +{ + for (const auto &coverArtFile : COVER_ART_FILES) + { + if (fileName == coverArtFile) + { + return true; + } + } + return false; +} + + void AudioDirectoryInfo::SetTemporaryDirectory(const std::filesystem::path &path) { temporaryDirectory = path; @@ -96,10 +131,9 @@ namespace class AudioDirectoryInfoImpl : public AudioDirectoryInfo { public: - AudioDirectoryInfoImpl(const std::filesystem::path &path, const std::filesystem::path&indexPath) + AudioDirectoryInfoImpl(const std::filesystem::path &path, const std::filesystem::path &indexPath) : path(path), indexPath( - (indexPath.empty() ? path: indexPath) / ".index.pipedal" - ) + (indexPath.empty() ? path : indexPath) / ".index.pipedal") { if (this->indexPath.parent_path() != path) { @@ -129,7 +163,6 @@ namespace private: std::vector QueryTracks(); - bool opened = false; std::filesystem::path indexPath; void OpenAudioDb(); @@ -139,7 +172,6 @@ namespace fs::path path; using id_t = int64_t; - std::vector UpdateDbFiles(); void DbSetThumbnailType( int64_t idFile, @@ -156,16 +188,14 @@ namespace void UpdateMetadata(DbFileInfo *dbFile); static constexpr int DB_VERSION = 1; 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::filesystem::path &path, const std::filesystem::path &indexPath) { - return std::make_shared(path,indexPath); + return std::make_shared(path, indexPath); } - std::vector AudioDirectoryInfoImpl::QueryTracks() { if (audioFilesDb) @@ -264,7 +294,18 @@ std::vector AudioDirectoryInfoImpl::UpdateDbFiles() auto path = dirEntry.path(); std::string name = path.filename(); std::string extension = path.extension(); - if (isAudioExtension(extension)) + if (isAudioExtension(extension) || isArtworkFileName(name)) + { + // If the file is an audio file or a cover art file, we need to check it. + if (name.empty() || name[0] == '.') + { + continue; // skip hidden files. + } + } + else + { + continue; // not an audio file or cover art file. + } { int64_t lastModified = fileTimeToInt64(dirEntry.last_write_time()); @@ -322,7 +363,7 @@ std::vector AudioDirectoryInfoImpl::UpdateDbFiles() } for (auto &dbFile : dbFiles) { - if (dbFile.thumbnailType() == ThumbnailType::Unknown) + if (dbFile.thumbnailType() == ThumbnailType::None) { if (!folderFileName.empty()) { @@ -410,27 +451,67 @@ void AudioDirectoryInfoImpl::DbDeleteFile(DbFileInfo *dbFile) audioFilesDb->DeleteFile(dbFile); } } +ThumbnailTemporaryFile AudioDirectoryInfoImpl::DefaultThumbnailTemporaryFile() +{ + ThumbnailTemporaryFile tempFile; + fs::path defaultThumbnail = GetResourceDirectory() / "img/missing_thumbnail.jpg"; + tempFile.SetNonDeletedPath(defaultThumbnail, "image/jpeg"); + return tempFile; +} -// All the varous cover art files I found on my personal music collection. -// These are used to find the cover art for a directory. Based on -// scanning a large music collection that have been tagged by various tools -// including iTunes, Windows Media Playe, and a variety of taggers. -// -// Files are listed in order of preference. +ThumbnailTemporaryFile AudioDirectoryInfoImpl::GetUnindexedThunbnail( + const std::string &fileNameOnly, int32_t width, int32_t height) +{ + fs::path file = this->path / fileNameOnly; + if (!fs::exists(file)) + { + return DefaultThumbnailTemporaryFile(); + } + try + { + auto tempFile = pipedal::GetAudioFileThumbnail( + file, width, height, + GetTemporaryDirectory()); + auto thumbnailPath = tempFile.Path(); + ThumbnailTemporaryFile result; + result.Attach(tempFile.Detach(), "image/jpeg"); -static std::vector COVER_ART_FILES = { - "Folder.jpg", // window media player/explorer. - "Cover.jpg", // itunes. - "folder.jpg", // Linux audio players plex, kodi, Rythmbox, Amorok, Clementine. - "cover.jpg", // Linux audio players plex, kodi, Rythmbox, Amorok, Clementine. - "Artwork.jpg", // itunes. - "Front.jpg", - "front.jpg", // linux. - "AlbumArt.jpg", - "albumArt.jpg", - "Frontcover.jpg", - "AlbumArtSmall.jpg" // windows media player. -}; + // Read the thumbnail data from the temporary file. + std::ifstream thumbnailStream(thumbnailPath, std::ios::binary); + if (!thumbnailStream) + { + throw std::runtime_error("Failed to open thumbnail file: " + thumbnailPath.string()); + } + std::vector thumbnailData( + (std::istreambuf_iterator(thumbnailStream)), + std::istreambuf_iterator()); + + if (!thumbnailData.empty()) + { + audioFilesDb->AddThumbnail( + fileNameOnly, + width, + height, + thumbnailData); + + // Set the MIME type for the thumbnail. + result.SetMimeType("image/jpeg"); + audioFilesDb->UpdateThumbnailInfo( + fileNameOnly, + ThumbnailType::Embedded); + return result; + } + else + { + throw std::runtime_error("Thumbnail data is empty."); + } + } + catch (const std::exception &e) + { + Lv2Log::error("GetUnindexedThunbnail failed: %s", e.what()); + } + return DefaultThumbnailTemporaryFile(); +} fs::path AudioDirectoryInfoImpl::GetFolderFile() const { @@ -604,22 +685,6 @@ ThumbnailTemporaryFile AudioDirectoryInfoImpl::GetThumbnail(const std::string &f return GetUnindexedThunbnail(fileNameOnly, width, height); } -ThumbnailTemporaryFile AudioDirectoryInfoImpl::GetUnindexedThunbnail(const std::string &fileNameOnly, int32_t width, int32_t height) -{ - fs::path file = this->path / fileNameOnly; - ThumbnailTemporaryFile tempFile = ThumbnailTemporaryFile::CreateTemporaryFile(GetTemporaryDirectory(), "image/jpeg"); - try - { - auto thumbnailPath = pipedal::GetAudioFileThumbnail(file, width, height, GetTemporaryDirectory()); - tempFile.Attach(thumbnailPath.Detach(), "image/jpeg"); - return tempFile; - } - catch (const std::exception &e) - { - // If we cannot get the thumbnail, return a default one. - return DefaultThumbnailTemporaryFile(); - } -} void AudioDirectoryInfoImpl::UpdateMetadata(DbFileInfo *dbFile) { @@ -687,13 +752,6 @@ void AudioDirectoryInfoImpl::OpenAudioDb() } } -ThumbnailTemporaryFile AudioDirectoryInfoImpl::DefaultThumbnailTemporaryFile() -{ - ThumbnailTemporaryFile tempFile; - fs::path defaultThumbnail = GetResourceDirectory() / "img/missing_thumbnail.jpg"; - tempFile.SetNonDeletedPath(defaultThumbnail, "image/jpeg"); - return tempFile; -} std::string AudioDirectoryInfoImpl::GetNextAudioFile(const std::string &fileNameOnly) { @@ -801,8 +859,8 @@ void AudioDirectoryInfoImpl::MoveAudioFile( transaction->commit(); } - -namespace pipedal { +namespace pipedal +{ std::filesystem::path IndexPathToShadowIndexPath(const std::filesystem::path &audioRootDirectory, const std::filesystem::path &path) { std::filesystem::path relativePath = MakeRelativePath(path, audioRootDirectory); diff --git a/src/AudioFiles.hpp b/src/AudioFiles.hpp index f3c1945..2d9fabe 100644 --- a/src/AudioFiles.hpp +++ b/src/AudioFiles.hpp @@ -124,4 +124,5 @@ namespace pipedal 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); + bool isArtworkFileName(const std::string &fileName); } \ No newline at end of file diff --git a/src/PiPedalModel.cpp b/src/PiPedalModel.cpp index 2c13596..ab37917 100644 --- a/src/PiPedalModel.cpp +++ b/src/PiPedalModel.cpp @@ -2407,6 +2407,17 @@ std::string PiPedalModel::RenameFilePropertyFile( return storage.RenameFilePropertyFile(oldRelativePath, newRelativePath, uiFileProperty); } +std::string PiPedalModel::CopyFilePropertyFile( + const std::string &oldRelativePath, + const std::string &newRelativePath, + const UiFileProperty &uiFileProperty, + bool overwrite) +{ + std::lock_guard lock(mutex); + return storage.CopyFilePropertyFile(oldRelativePath, newRelativePath, uiFileProperty,overwrite); +} + + void PiPedalModel::DeleteSampleFile(const std::filesystem::path &fileName) { std::lock_guard lock(mutex); diff --git a/src/PiPedalModel.hpp b/src/PiPedalModel.hpp index a7de722..1fba8e2 100644 --- a/src/PiPedalModel.hpp +++ b/src/PiPedalModel.hpp @@ -454,6 +454,7 @@ namespace pipedal void DeleteSampleFile(const std::filesystem::path &fileName); std::string CreateNewSampleDirectory(const std::string &relativePath, const UiFileProperty &uiFileProperty); std::string RenameFilePropertyFile(const std::string &oldRelativePath, const std::string &newRelativePath, const UiFileProperty &uiFileProperty); + std::string CopyFilePropertyFile(const std::string &oldRelativePath, const std::string &newRelativePath, const UiFileProperty &uiFileProperty,bool overwrite); FilePropertyDirectoryTree::ptr GetFilePropertydirectoryTree(const UiFileProperty &uiFileProperty,const std::string&selectedPath); bool IsInUploadsDirectory(const std::string &path); diff --git a/src/PiPedalSocket.cpp b/src/PiPedalSocket.cpp index 3f85297..ae4f965 100644 --- a/src/PiPedalSocket.cpp +++ b/src/PiPedalSocket.cpp @@ -115,6 +115,23 @@ JSON_MAP_REFERENCE(RenameSampleFileArgs, newRelativePath) JSON_MAP_REFERENCE(RenameSampleFileArgs, uiFileProperty) JSON_MAP_END() +class CopySampleFileArgs +{ +public: + std::string oldRelativePath_; + std::string newRelativePath_; + UiFileProperty uiFileProperty_; + bool overwrite_ = false; + DECLARE_JSON_MAP(CopySampleFileArgs); +}; + +JSON_MAP_BEGIN(CopySampleFileArgs) +JSON_MAP_REFERENCE(CopySampleFileArgs, oldRelativePath) +JSON_MAP_REFERENCE(CopySampleFileArgs, newRelativePath) +JSON_MAP_REFERENCE(CopySampleFileArgs, uiFileProperty) +JSON_MAP_REFERENCE(CopySampleFileArgs, overwrite) +JSON_MAP_END() + class GetFilePropertyDirectoryTreeArgs { public: @@ -1645,6 +1662,14 @@ public: std::string newFileName = this->model.RenameFilePropertyFile(args.oldRelativePath_, args.newRelativePath_, args.uiFileProperty_); this->Reply(replyTo, "renameFilePropertyFile", newFileName); } + else if (message == "copyFilePropertyFile") + { + CopySampleFileArgs args; + pReader->read(&args); + + std::string newFileName = this->model.CopyFilePropertyFile(args.oldRelativePath_, args.newRelativePath_, args.uiFileProperty_, args.overwrite_); + this->Reply(replyTo, "copyFilePropertyFile", newFileName); + } else if (message == "getFilePropertyDirectoryTree") { GetFilePropertyDirectoryTreeArgs args; diff --git a/src/Storage.cpp b/src/Storage.cpp index 1e959fe..217c782 100644 --- a/src/Storage.cpp +++ b/src/Storage.cpp @@ -1705,6 +1705,13 @@ static void AddTracksToResult( std::set validExtensions = fileProperty.GetPermittedFileExtensions( modDirectoryInfo ? modDirectoryInfo->modType : ""); + if (validExtensions.size() == 0) + { + const auto &audioExtensions = MimeTypes::instance().AudioExtensions(); + validExtensions.insert(audioExtensions.begin(), audioExtensions.end()); + } + validExtensions.insert(".jpg"); + validExtensions.insert(".png"); try { // Add directories first. @@ -1828,9 +1835,11 @@ FileRequestResult Storage::GetModFileList2(const std::string &relativePath, cons ++iRp; } } + fs::path cumulativePath = modDirectoryPath; while (iRp != rp.end()) { - result.breadcrumbs_.push_back({*iRp, *iRp}); + cumulativePath /= (*iRp); + result.breadcrumbs_.push_back({cumulativePath, *iRp}); ++iRp; } } @@ -2036,6 +2045,17 @@ std::filesystem::path Storage::MakeUserFilePath(const std::string &directory, co } return result; } + + +bool Storage::IsValidArtworkFile(const std::filesystem::path& fullPath) +{ + if (IsInAudioTracksDirectory(fullPath) && isArtworkFileName(fullPath.filename().string())) + { + // allow artwork files. + return true; + } + return false; +} std::string Storage::UploadUserFile(const std::string &directory, UiFileProperty::ptr uiFileProperty, const std::string &filename, @@ -2060,7 +2080,7 @@ std::string Storage::UploadUserFile(const std::string &directory, throw std::logic_error("Permission denied. Path is outside the upload storage directory."); } - if (!uiFileProperty->IsValidExtension(relativePath)) + if (!(uiFileProperty->IsValidExtension(relativePath) || IsValidArtworkFile(path))) { throw std::logic_error("Permission denied. Invalid file extension for this directory."); } @@ -2162,6 +2182,52 @@ std::string Storage::RenameFilePropertyFile( std::filesystem::rename(oldPath, newPath); return newPath; } +std::string Storage::CopyFilePropertyFile( + const std::string &oldRelativePath, + const std::string &newRelativePath, + const UiFileProperty &uiFileProperty, + bool overwrite) +{ + if (uiFileProperty.directory().empty()) + { + throw std::runtime_error("Invalid UI File Property."); + } + std::filesystem::path oldPath = this->GetPluginUploadDirectory() / uiFileProperty.directory() / oldRelativePath; + if (!this->IsValidSampleFileName(oldPath)) + { + throw std::runtime_error("Invalid file name."); + } + if (!std::filesystem::exists(oldPath)) + { + throw std::runtime_error("Original path does not exist."); + } + + std::filesystem::path newPath = this->GetPluginUploadDirectory() / uiFileProperty.directory() / newRelativePath; + + + if (!this->IsValidSampleFileName(newPath)) + { + throw std::runtime_error("Invalid file name."); + } + if (std::filesystem::exists(newPath)) + { + if (std::filesystem::is_directory(newPath)) + { + throw std::runtime_error("A directory with that name already exists."); + } + else + { + if (!overwrite) { + return ""; // signal a portential overwrite. + } else { + fs::remove(newPath); + } + } + } + + std::filesystem::create_hard_link(oldPath, newPath); + return newPath; +} void Storage::FillSampleDirectoryTree(FilePropertyDirectoryTree *node, const std::filesystem::path &directory) const { diff --git a/src/Storage.hpp b/src/Storage.hpp index 41c4640..440ca17 100644 --- a/src/Storage.hpp +++ b/src/Storage.hpp @@ -156,6 +156,7 @@ public: bool IsInUploadsDirectory(const std::filesystem::path&path) const; bool IsInAudioTracksDirectory(const std::filesystem::path&path) const; + bool IsValidArtworkFile(const std::filesystem::path& fullPath); FileRequestResult GetFileList2(const std::string&relativePath,const UiFileProperty&fileProperty); @@ -232,6 +233,11 @@ public: const std::string&oldRelativePath, const std::string&newRelativePath, const UiFileProperty&uiFileProperty); + std::string CopyFilePropertyFile( + const std::string&oldRelativePath, + const std::string&newRelativePath, + const UiFileProperty&uiFileProperty, + bool overwrite = false); FilePropertyDirectoryTree::ptr GetFilePropertydirectoryTree(const UiFileProperty&uiFileProperty,const std::filesystem::path&selectedPath); }; diff --git a/todo.txt b/todo.txt index 8c66338..ac43064 100644 --- a/todo.txt +++ b/todo.txt @@ -1,9 +1,14 @@ -Serialize sort order. +Numeric up-down buttons blown in light theme. + +Tooltips on touch ui? + +pre-cue next decoder stream in big loops. + +convolution reverb broken. + - pipewire aux in? -libsqlite3-dev install -libsqlitecpp-dev install Check that OnNotifyPatchProperty still worsk for - FilePropertyControl diff --git a/vite/public/img/missing_thumbnail.jpg b/vite/public/img/missing_thumbnail.jpg index 3fff724..1083840 100644 Binary files a/vite/public/img/missing_thumbnail.jpg and b/vite/public/img/missing_thumbnail.jpg differ diff --git a/vite/src/pipedal/App.tsx b/vite/src/pipedal/App.tsx index cb08a14..d1ab4dd 100644 --- a/vite/src/pipedal/App.tsx +++ b/vite/src/pipedal/App.tsx @@ -58,8 +58,50 @@ const theme = createTheme( { cssVariables: true, components: { + // MuiTouchRipple: { + // styleOverrides: { + // root: { + // borderRadius: 'inherit', + // overflow: 'hidden', + // }, + // ripple: { + // color: '#F88 !important', + // borderRadius: 'inherit', + + // '&.MuiTouchRipple-ripplePulsate': { + // //animation: 'none !important', + + // // Make focus ripple fill the entire button + // '&.MuiTouchRipple-child': { + // width: '100%', + // height: '100%', + // borderRadius: 'inherit', + // transform: 'scale(1.4)', // Override the default scaling + // } + // }, + // '&.MuiTouchRipple-ripple': { + // '&:focus': { + // // Make focus ripple fill the entire button + // transform: 'scale(1.4)', + // width: '100%', + // height: '100%', + // color: '#F88', + // borderRadius: 'inherit', + // }, + // }, + // }, + // child: { + // borderRadius: 'inherit', + // } + // } + // }, MuiButton: { styleOverrides: { + root: { + '& .MuiTouchRipple-ripple': { + transform: 'scale(1.9)', + } + }, containedPrimary: { borderRadius: '9999px', paddingLeft: "16px", paddingRight: "16px", @@ -80,7 +122,7 @@ const theme = createTheme( } }, { - props: { variant: 'dialogSecondary', }, + props: { variant: 'dialogSecondary', }, style: { color: "rgb(255,255,255,0.7)" }, @@ -108,18 +150,26 @@ const theme = createTheme( /* make the selection state for MuiListItemButtons a smidgen darker (light theme only) */ MuiListItemButton: { styleOverrides: { - root: ({ theme }) => ({ - '&.Mui-selected': { - backgroundColor: 'rgba(0, 0, 0, 0.2)', // Adjust for desired darkness - '&:hover': { - backgroundColor: 'rgba(0, 0, 0, 0.25)', // Slightly darker on hover - }, - }, - }), + root: ({ theme }) => ({ + '&.Mui-selected': { + backgroundColor: 'rgba(0, 0, 0, 0.2)', // Adjust for desired darkness + '&:hover': { + backgroundColor: 'rgba(0, 0, 0, 0.25)', // Slightly darker on hover + }, + }, + }), }, }, MuiButton: { styleOverrides: { + root: { + '& .MuiTouchRipple-root': { + borderRadius: 'inherit', + }, + '& .MuiTouchRipple-ripple': { + transform: 'scale(1.9)!important', + } + }, containedPrimary: { borderRadius: '9999px', paddingLeft: "16px", paddingRight: "16px", @@ -140,7 +190,7 @@ const theme = createTheme( } }, { - props: { variant: 'dialogSecondary', }, + props: { variant: 'dialogSecondary', }, style: { color: "rgb(0,0,0,0.6)" }, @@ -178,8 +228,7 @@ const App = (class extends React.Component { super(props); this.state = { }; - if (!App.virtualKeyboardHandler) - { + if (!App.virtualKeyboardHandler) { App.virtualKeyboardHandler = new VirtualKeyboardHandler(); } } @@ -192,7 +241,7 @@ const App = (class extends React.Component { - + ); diff --git a/vite/src/pipedal/AppThemed.css b/vite/src/pipedal/AppThemed.css index da48566..9300251 100644 --- a/vite/src/pipedal/AppThemed.css +++ b/vite/src/pipedal/AppThemed.css @@ -43,3 +43,4 @@ input[type=number] { + diff --git a/vite/src/pipedal/ButtonEx.tsx b/vite/src/pipedal/ButtonEx.tsx new file mode 100644 index 0000000..92d3b6f --- /dev/null +++ b/vite/src/pipedal/ButtonEx.tsx @@ -0,0 +1,49 @@ +/* + * 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 Button, {ButtonProps} from '@mui/material/Button'; +import Tooltip from "@mui/material/Tooltip"; +import Typography from "@mui/material/Typography"; + + +interface ButtonExProps extends ButtonProps { + tooltip: string; + style?: React.CSSProperties; +}; +function ButtonEx(props: ButtonExProps) { + const { tooltip, style, ...extra } = props; + + return ( + {tooltip || extra['aria-label']} + ) + } + placement="top-start" arrow + enterDelay={1500} enterNextDelay={1500} + > + + - +
 
@@ -1144,7 +1180,8 @@ export default withStyles( @@ -1182,7 +1219,8 @@ export default withStyles( @@ -1200,6 +1238,7 @@ export default withStyles( this.setState({ openUploadFileDialog: false }); } } + isTracksDirectory={this.isTracksDirectory()} uploadPage={ "uploadUserFile?directory=" + encodeURIComponent(this.state.currentDirectory) + "&id=" + this.props.instanceId.toString() @@ -1224,6 +1263,17 @@ export default withStyles( }} /> + { this.handleConfirmCopy(); }} + onClose={() => { + this.setState({ confirmCopyDialogState: null }); + }} + + /> + { this.state.newFolderDialogOpen && ( { this.setState({ moveDialogOpen: false }); }} + onClose={() => { this.setState({ moveDialogOpen: false, copyDialogOpen: false }); }} onOk={ (path) => { this.setState({ moveDialogOpen: false }); this.setDefaultPath(path); - this.onExecuteMove(path); + if (this.state.copyDialogOpen) { + this.onExecuteCopy(path); + } else { + this.onExecuteMove(path); + } } } @@ -1272,6 +1327,9 @@ export default withStyles( ); } openSelectedFile(): void { + if (this.isFolderArtwork(this.state.selectedFile)) { + return; + } if (this.isDirectory(this.state.selectedFile)) { this.requestFiles(this.state.selectedFile); this.setState({ navDirectory: this.state.selectedFile }); @@ -1292,6 +1350,9 @@ export default withStyles( private onMove(): void { this.setState({ moveDialogOpen: true }); } + private onCopy(): void { + this.setState({ copyDialogOpen: true }); + } private onExecuteMove(newDirectory: string) { let fileName = pathFileName(this.state.selectedFile); let oldFilePath = pathConcat(this.state.navDirectory, fileName); @@ -1305,6 +1366,46 @@ export default withStyles( this.model.showAlert(e.toString()); }); + } + private handleConfirmCopy() { + if (this.state.confirmCopyDialogState) { + this.model.copyFilePropertyFile( + this.state.confirmCopyDialogState.oldFilePath, + this.state.confirmCopyDialogState.newFilePath, + this.props.fileProperty, true) + .then((filename) => { + this.requestFiles(this.state.navDirectory); + }) + .catch((e) => { + this.model.showAlert(e.toString()); + }); + this.setState({ confirmCopyDialogState: null }); + } + } + private onExecuteCopy(newDirectory: string) { + this.setState({ copyDialogOpen: false }); + let fileName = pathFileName(this.state.selectedFile); + let oldFilePath = pathConcat(this.state.navDirectory, fileName); + let newFilePath = pathConcat(newDirectory, fileName); + + this.model.copyFilePropertyFile(oldFilePath, newFilePath, this.props.fileProperty, false) + .then((filename) => { + if (filename === "") { + // the file already exists. prompt for overrwrite. + this.setState({ + confirmCopyDialogState: { + fileName: fileName, + oldFilePath: oldFilePath, + newFilePath + } + }); + + } + }) + .catch((e) => { + this.model.showAlert(e.toString()); + }); + } private onRename(): void { diff --git a/vite/src/pipedal/IconButtonEx.tsx b/vite/src/pipedal/IconButtonEx.tsx new file mode 100644 index 0000000..e54692f --- /dev/null +++ b/vite/src/pipedal/IconButtonEx.tsx @@ -0,0 +1,50 @@ +/* + * 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 IconButton, {IconButtonProps} from '@mui/material/IconButton'; +import Tooltip from "@mui/material/Tooltip"; +import Typography from "@mui/material/Typography"; + +interface IconButtonExProps extends IconButtonProps { + tooltip: string; + style?: React.CSSProperties; +}; + +function IconButtonEx(props: IconButtonExProps) { + const { tooltip,style, ...extra } = props; + + return ( + {tooltip || extra['aria-label'] } + ) + } + placement="top-start" arrow + enterDelay={1500} enterNextDelay={1500} + > + + + ); +} + +export default IconButtonEx; \ No newline at end of file diff --git a/vite/src/pipedal/LoopDialog.tsx b/vite/src/pipedal/LoopDialog.tsx index a177ac2..66c9ab9 100644 --- a/vite/src/pipedal/LoopDialog.tsx +++ b/vite/src/pipedal/LoopDialog.tsx @@ -36,17 +36,22 @@ import Slider, { SliderProps } from "@mui/material/Slider"; import Checkbox from "@mui/material/Checkbox"; import TimebaseSelectorDialog from "./TimebaseselectorDialog"; import Timebase, { TimebaseUnits } from "./Timebase"; +import { useWindowHeight } from "@react-hook/window-size"; +import Button from "@mui/material/Button"; +import PlayArrow from "@mui/icons-material/PlayArrow"; +import StopIcon from '@mui/icons-material/Stop'; +import { LoopParameters } 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; + onSetLoop: (value: LoopParameters) => void; + value: LoopParameters; + playing: boolean; + onPreview: () => void; + onCancelPlaying: () => void; duration: number; - fullScreen?: boolean; timebase: Timebase; onTimebaseChange: (timebase: Timebase) => void; // Optional callback for timebase changes sampleRate: number; @@ -74,9 +79,9 @@ function parseTime(timebase: Timebase, sampleRate: number, duration: number, tim if (isNaN(samples)) { return samples; } - let result = samples / sampleRate; // Convert samples to seconds + let result = samples / sampleRate; // Convert samples to seconds if (result < 0) { - result = 0; + result = 0; } if (result > duration) { result = duration; @@ -118,13 +123,13 @@ function parseTime(timebase: Timebase, sampleRate: number, duration: number, tim let result = hours * 60 * 60 + minutes * 60 + seconds; // Convert total time to seconds if (result < 0) { - result = 0; + result = 0; } if (result > duration) { result = duration; } return result; - + } case TimebaseUnits.Beats: { @@ -141,9 +146,9 @@ function parseTime(timebase: Timebase, sampleRate: number, duration: number, tim 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}.`); - } + 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) { @@ -153,16 +158,16 @@ function parseTime(timebase: Timebase, sampleRate: number, duration: number, tim 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}.`); + 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 + 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; + result = 0; } if (result > duration) { result = duration; @@ -172,7 +177,7 @@ function parseTime(timebase: Timebase, sampleRate: number, duration: number, tim } } -function formatTime(timebase: Timebase, sampleRate: number, seconds: number): string { +export function formatTime(timebase: Timebase, sampleRate: number, seconds: number): string { switch (timebase.units) { case TimebaseUnits.Samples: { @@ -197,7 +202,7 @@ function formatTime(timebase: Timebase, sampleRate: number, seconds: number): st 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) }`; + return `${bars + 1}:${(beat + 1).toFixed(4)}`; } break; default: @@ -320,22 +325,23 @@ function SliderWithPreview(props: SliderWithPreviewProps) { function TimeEdit(props: TimeEditProps) { - let { value, onValueChange, onBlur, timebase,sampleRate,max, ...extra } = props; + 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 ) { + if (!focus) { setText(formatTime(timebase, sampleRate, props.value)); - } + } }, - [props.value]); + [props.value]); React.useEffect(() => { - setText(formatTime(props.timebase, sampleRate, props.value)); + if (!focus) { + setText(formatTime(props.timebase, sampleRate, props.value)); + } }, [props.timebase, sampleRate]); @@ -348,11 +354,9 @@ function TimeEdit(props: TimeEditProps) { 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); + onBlur(); }} onChange={(e) => { try { @@ -362,7 +366,6 @@ function TimeEdit(props: TimeEditProps) { if (val > max) { val = max; } - setEditValue(val); if (props.onValueChange) { props.onValueChange(e, val); } @@ -389,34 +392,64 @@ function TimeEdit(props: TimeEditProps) { autoCapitalize: "off", }} /> + ); } export default function LoopDialog(props: LoopDialogProps) { - const [previewStartValue, setPreviewStartValue] = React.useState(null); - const [previewLoopStart, setPreviewLoopStart] = React.useState(null); - const [previewLoopEnd, setPreviewLoopEnd] = React.useState(null); + const [start, setStart] = React.useState(props.value.start); + const [loopEnable, setLoopEnable] = React.useState(props.value.loopEnable); + const [loopStart, setLoopStart] = React.useState(props.value.loopStart); + const [loopEnd, setLoopEnd] = React.useState(props.value.loopEnd); + const height = useWindowHeight(); + const fullScreen = height < 500; + + function cancelPlaying() { + props.onCancelPlaying(); + } + function commitResults() { + let tLoopStart = loopStart; + let tLoopEnd = loopEnd; + if (tLoopEnd < tLoopStart) { + let tmp = tLoopStart; + tLoopStart = tLoopEnd; + tLoopEnd = tmp; + setLoopStart(tLoopStart); + setLoopEnd(tLoopEnd); + } + if (props.onSetLoop) { + props.onSetLoop({ start: start, loopEnable: loopEnable, loopStart: tLoopStart, loopEnd: tLoopEnd }); + } + } + React.useEffect(() => { + setStart(props.value.start); + setLoopEnable(props.value.loopEnable); + setLoopStart(props.value.loopStart); + setLoopEnd(props.value.loopEnd); + }, [props.value]); return ( { + commitResults(); + props.onClose(); + }} maxWidth="xl" open={props.isOpen} onEnterKey={() => { }} - + fullScreen={height < 500} sx={{ "& .MuiDialog-container": { "& .MuiPaper-root": { width: "100%", - maxWidth: "500px", // Set your width here + maxWidth: fullScreen ? undefined : "500px", // Set your width here }, }, }} @@ -428,7 +461,10 @@ export default function LoopDialog(props: LoopDialogProps) { color="inherit" aria-label="back" style={{ opacity: 0.6 }} - onClick={() => { props.onClose(); }} + onClick={() => { + commitResults(); + props.onClose(); + }} > @@ -449,8 +485,8 @@ export default function LoopDialog(props: LoopDialogProps) {
@@ -460,53 +496,44 @@ export default function LoopDialog(props: LoopDialogProps) { style={{ width: "100%" }} - value={props.start} + value={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); - } + setStart(val); }} onPreview={(e, v, thumb) => { let val = Array.isArray(v) ? v[0] : v - setPreviewStartValue(val); + setStart(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); - } + setStart(val); + cancelPlaying(); + }} valueLabelFormat={(v) => formatTime(props.timebase, props.sampleRate, v)} /> { - let t = props.start; - if (t > props.duration) { - t = props.duration; - if (props.onSetLoop) { - props.onSetLoop(t, props.loopEnable, props.loopStart, props.loopEnd); - } + if (start > props.duration) { + setStart(props.duration); } }} onValueChange={(e, val) => { - if (props.onSetLoop) { - props.onSetLoop(val, props.loopEnable, props.loopStart, props.loopEnd); - } + setStart(val); + cancelPlaying(); } } style={{ width: 120, alignSelf: "center", textAlign: "center" }} /> @@ -514,9 +541,10 @@ export default function LoopDialog(props: LoopDialogProps) { { - props.onSetLoop(props.start, !props.loopEnable, props.loopStart, props.loopEnd); + setLoopEnable(checked); + cancelPlaying(); }} />} label="Enable loop" /> @@ -525,102 +553,92 @@ export default function LoopDialog(props: LoopDialogProps) { { - 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]); - } + if (!Array.isArray(v)) throw new Error("Expected array for loop start and end"); + + setLoopStart(v[0]); + setLoopEnd(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]); + if (!Array.isArray(v)) throw new Error("Expected array for loop start and end"); + + setLoopStart(v[0]); + setLoopEnd(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]); + if (!Array.isArray(v)) throw new Error("Expected array for loop start and end"); + + setLoopStart(v[0]); + setLoopEnd(v[1]); + cancelPlaying(); + }} />
{ - 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); - } + setLoopStart(val); + cancelPlaying(); + + }} /> { - 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); - } + setLoopEnd(val); + cancelPlaying(); + }} />
+
diff --git a/vite/src/pipedal/MainPage.tsx b/vite/src/pipedal/MainPage.tsx index e308119..f618bc7 100644 --- a/vite/src/pipedal/MainPage.tsx +++ b/vite/src/pipedal/MainPage.tsx @@ -21,12 +21,14 @@ import { SyntheticEvent } from 'react'; import { Theme } from '@mui/material/styles'; import WithStyles, {withTheme} from './WithStyles'; import { withStyles } from "tss-react/mui"; +import IconButtonEx from './IconButtonEx'; +import ButtonEx from './ButtonEx'; +import Tooltip from '@mui/material/Tooltip'; import { PiPedalModel, PiPedalModelFactory } from './PiPedalModel'; import { Pedalboard, PedalboardItem, PedalboardSplitItem, SplitType } from './Pedalboard'; -import Button from '@mui/material/Button'; import InputIcon from '@mui/icons-material/Input'; import LoadPluginDialog from './LoadPluginDialog'; import Switch from '@mui/material/Switch'; @@ -34,7 +36,6 @@ import Typography from '@mui/material/Typography'; import PedalboardView from './PedalboardView'; import { PiPedalStateError } from './PiPedalError'; -import IconButton from '@mui/material/IconButton'; import AddIcon from '@mui/icons-material/Add'; import Menu from '@mui/material/Menu'; import MenuItem from '@mui/material/MenuItem'; @@ -529,7 +530,9 @@ export const MainPage = }} >
- + + +
{ @@ -541,9 +544,9 @@ export const MainPage = {this.props.enableStructureEditing && (
- { this.onAddClick(e) }} size="large"> + { this.onAddClick(e) }} size="large"> - +
- { this.onDeletePedal(pedalboardItem?.instanceId ?? -1) }} size="large"> - +
- +
- { this.handleMidiConfiguration(instanceId); }} size="large"> - +
- { this.setState({ snapshotDialogOpen: true }); }} size="large"> {this.snapshotIcon(this.props.theme, this.state.selectedSnapshot)} - +
)} diff --git a/vite/src/pipedal/PiPedalModel.tsx b/vite/src/pipedal/PiPedalModel.tsx index f5fd124..dd9e011 100644 --- a/vite/src/pipedal/PiPedalModel.tsx +++ b/vite/src/pipedal/PiPedalModel.tsx @@ -2697,6 +2697,36 @@ export class PiPedalModel //implements PiPedalModel }); }); } + copyFilePropertyFile( + oldRelativePath: string, + newRelativePath: string, + uiFileProperty: UiFileProperty, + overwrite: boolean + ): Promise { + return new Promise((resolve, reject) => { + + let ws = this.webSocket; + if (!ws) { + resolve(""); + return; + } + ws.request( + "copyFilePropertyFile", + { + oldRelativePath: oldRelativePath, + newRelativePath: newRelativePath, + uiFileProperty: uiFileProperty, + overwrite: overwrite + } + ) + .then((newPath) => { + resolve(newPath); + }) + .catch((err) => { + reject(err); + }); + }); + } setWifiConfigSettings(wifiConfigSettings: WifiConfigSettings): Promise { let result = new Promise((resolve, reject) => { diff --git a/vite/src/pipedal/PluginControl.tsx b/vite/src/pipedal/PluginControl.tsx index eeb103d..1d1b86a 100644 --- a/vite/src/pipedal/PluginControl.tsx +++ b/vite/src/pipedal/PluginControl.tsx @@ -23,6 +23,7 @@ import Button from '@mui/material/Button'; import { Theme } from '@mui/material/styles'; import WithStyles, { withTheme } from './WithStyles'; import { createStyles } from './WithStyles'; +import ControlTooltip from './ControlTooltip'; import { withStyles } from "tss-react/mui"; import { UiControl, ScalePoint } from './Lv2Plugin'; @@ -35,7 +36,7 @@ import MenuItem from '@mui/material/MenuItem'; import { PiPedalModel, PiPedalModelFactory } from './PiPedalModel'; import DialIcon from './svg/fx_dial.svg?react'; import { isDarkMode } from './DarkMode'; -import ControlTooltip from './ControlTooltip'; + import PlayArrowIcon from '@mui/icons-material/PlayArrow'; import StopIcon from '@mui/icons-material/Stop'; @@ -699,45 +700,51 @@ const PluginControl = if (control.isOnOffSwitch()) { // normal gray unchecked state. return ( - { - this.onCheckChanged(event.target.checked); - }} - /> + + { + this.onCheckChanged(event.target.checked); + }} + /> + ); } if (control.isAbToggle()) { let classes = withStyles.getClasses(this.props); // unchecked color is not gray. return ( - { - this.onCheckChanged(event.target.checked); - }} - classes={{ - track: classes.switchTrack - }} - style={{ color: this.props.theme.palette.primary.main }} - /> + + { + this.onCheckChanged(event.target.checked); + }} + classes={{ + track: classes.switchTrack + }} + style={{ color: this.props.theme.palette.primary.main }} + /> + ); } else { return ( - + {control.scale_points.map((scale_point: ScalePoint) => ( + {scale_point.label} - ))} - + ))} + + ); } } @@ -919,12 +926,10 @@ const PluginControl = alignSelf: "stretch", marginBottom: 8, marginLeft: isSelect ? 8 : 0, marginRight: 0 }}> - - {isButton ? "\u00A0" : control.name} - + {isButton ? "\u00A0" : control.name} {/* CONTROL SECTION */} @@ -933,81 +938,86 @@ const PluginControl = {isButton ? ( control.name.length !== 1 ? ( - + > + {control.name} + + ) : ( - + > + {androidEmoji(control.name)} + + ) ) @@ -1016,34 +1026,38 @@ const PluginControl = ) : (isGraphicEq) ? (
- + + /> +
) : (
- + + /> +
) } @@ -1079,11 +1093,11 @@ const PluginControl = }, }} - inputRef={this.inputRef} onChange={this.onInputChange} - onBlur={this.onInputLostFocus} - onFocus={this.onInputFocus} + inputRef={this.inputRef} onChange={this.onInputChange} + onBlur={this.onInputLostFocus} + onFocus={this.onInputFocus} - onKeyPress={this.onInputKeyPress} /> + onKeyPress={this.onInputKeyPress} />
{ this.inputRef.current!.focus(); }} style={{ display: this.state.editFocused ? "none" : "block" }} diff --git a/vite/src/pipedal/PluginControlView.tsx b/vite/src/pipedal/PluginControlView.tsx index 285fcf1..f6d597d 100644 --- a/vite/src/pipedal/PluginControlView.tsx +++ b/vite/src/pipedal/PluginControlView.tsx @@ -472,7 +472,6 @@ const PluginControlView = throw new PiPedalStateError("Missing control value."); } return (( - { this.onControlValueChanged(controlValue!.key, value) }} onPreviewChange={(value: number) => { this.onPreviewChange(controlValue!.key, value) }} @@ -480,6 +479,14 @@ const PluginControlView = /> )); + // return (( + // { this.onControlValueChanged(controlValue!.key, value) }} + // onPreviewChange={(value: number) => { this.onPreviewChange(controlValue!.key, value) }} + // requestIMEEdit={(uiControl: any, value: any) => this.requestImeEdit(uiControl, value)} + + // /> + // )); } diff --git a/vite/src/pipedal/PluginInfoDialog.tsx b/vite/src/pipedal/PluginInfoDialog.tsx index 6a37c86..5f3acf6 100644 --- a/vite/src/pipedal/PluginInfoDialog.tsx +++ b/vite/src/pipedal/PluginInfoDialog.tsx @@ -18,6 +18,7 @@ // CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. import React from 'react'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import { Theme } from '@mui/material/styles'; import WithStyles from './WithStyles'; import { createStyles } from './WithStyles'; @@ -27,8 +28,7 @@ import DialogEx from './DialogEx'; import MuiDialogTitle from '@mui/material/DialogTitle'; import MuiDialogContent from '@mui/material/DialogContent'; import MuiDialogActions from '@mui/material/DialogActions'; -import IconButton from '@mui/material/IconButton'; -import CloseIcon from '@mui/icons-material/Close'; +import IconButtonEx from './IconButtonEx'; import Typography from '@mui/material/Typography'; import Grid from '@mui/material/Grid'; import { PiPedalModelFactory } from "./PiPedalModel"; @@ -255,32 +255,36 @@ const PluginInfoDialog = withStyles((props: PluginInfoProps) => { return (
- - + {open && ( -
-
- +
+ + { handleClose()}} + > + + + +
+
{plugin.name}
- handleClose()} - style={{ flex: "0 0 auto" }} - size="large"> - -
diff --git a/vite/src/pipedal/PluginPresetSelector.tsx b/vite/src/pipedal/PluginPresetSelector.tsx index 8aa2f2a..9f41d78 100644 --- a/vite/src/pipedal/PluginPresetSelector.tsx +++ b/vite/src/pipedal/PluginPresetSelector.tsx @@ -18,7 +18,7 @@ // CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. import { SyntheticEvent, Component } from 'react'; -import IconButton from '@mui/material/IconButton'; +import IconButtonEx from './IconButtonEx'; import { PiPedalModel, PiPedalModelFactory, PluginPresetsChangedHandle } from './PiPedalModel'; import { Theme } from '@mui/material/styles'; import WithStyles from './WithStyles'; @@ -293,9 +293,11 @@ const PluginPresetSelector = } return (
- this.handlePresetMenuClick(e)} size="large"> + this.handlePresetMenuClick(e)} size="large"> - + ({ backgroundColor: 'rgba(255,255,255,0.4)', boxShadow: "1px 4px 12px rgba(0,0,0,0.2)", ...theme.applyStyles('dark', { - backgroundColor: useWallpaper? 'rgba(0,0,0,0.6)' : '#282828', + backgroundColor: useWallpaper ? 'rgba(0,0,0,0.6)' : '#282828', }), })); @@ -174,12 +242,6 @@ const CoverImage = styled('div')({ }, }); -const TinyText = styled(Typography)({ - fontSize: '0.85rem', - opacity: 0.9, - fontWeight: 400, - letterSpacing: 0.2, -}); export interface ToobPlayerControlProps { @@ -192,32 +254,41 @@ export default function ToobPlayerControl( ) { const model = PiPedalModelFactory.getInstance(); + const sampleRate = model.jackConfiguration.get().sampleRate == 0 ? + 48000 : + model.jackConfiguration.get().sampleRate; + const defaultCoverArt = "/img/default_album.jpg"; const [serverConnected, setServerConnected] = React.useState(model.state.get() == State.Ready); const [duration, setDuration] = React.useState(0.0); - const [position, setPosition] = React.useState(0.0); const [start, setStart] = React.useState(0.0); const [loopStart, setLoopStart] = React.useState(0.0); const [loopEnable, setLoopEnable] = React.useState(false); const [loopEnd, setLoopEnd] = React.useState(0.0); - const [dragging, setDragging] = React.useState(false); const [pluginState, setPluginState] = React.useState(0.0); - const [sliderValue, setSliderValue] = React.useState(0.0); const [coverArt, setCoverArt] = React.useState(defaultCoverArt); const [audioFile, setAudioFile] = React.useState(""); + const [position, setPosition] = React.useState(0.0); + //let position = 0; const [title, setTitle] = React.useState(""); const [album, setAlbum] = React.useState(""); const [artist, setArtist] = React.useState(""); const [albumArtist, setAlbumArtist] = React.useState(""); const [showFileDialog, setShowFileDialog] = React.useState(false); const [showLoopDialog, setShowLoopDialog] = React.useState(false); - const [timebase,setTimebase] = React.useState( + const [timebase, setTimebase] = React.useState( { units: TimebaseUnits.Seconds, tempo: 120.0, timeSignature: { numerator: 4, denominator: 4 } }); + const [loopParameters, setLoopParameters] = React.useState({ + start: 0.0, + loopEnable: false, + loopStart: 0.0, + loopEnd: 0.0 + }); const [size] = useWindowSize(); @@ -237,12 +308,6 @@ export default function ToobPlayerControl( function SelectFile() { setShowFileDialog(true); } - function formatDuration(value_: number) { - let value = Math.ceil(value_); - const minute = Math.floor(value / 60); - const secondLeft = value - minute * 60; - return `${minute}:${secondLeft < 10 ? `0${secondLeft}` : secondLeft}`; - } function onAudioFileChanged(path: string) { setAudioFile(path); if (path === "") { @@ -257,7 +322,7 @@ export default function ToobPlayerControl( .then((metadata) => { let strTrack: string = ""; if (metadata.track > 0) { - let disc = (metadata.track /1000); + let disc = (metadata.track / 1000); if (disc >= 1) { strTrack = (metadata.track % 1000).toString() + '/' + Math.floor(disc).toString(); } else { @@ -265,14 +330,14 @@ export default function ToobPlayerControl( } strTrack += ". "; } - let coverArtUri = getAlbumArtUri(model,metadata,path); + let coverArtUri = getAlbumArtUri(model, metadata, path); setCoverArt(coverArtUri); setTitle(strTrack + metadata.title); setAlbum(metadata.album); setArtist(metadata.artist); setAlbumArtist(metadata.albumArtist); }) - .catch((e)=>{ + .catch((e) => { setTitle("#error" + e.message); setAlbum(""); }); @@ -298,7 +363,7 @@ export default function ToobPlayerControl( { - if (position > 3) { + if (position > start + 3.0) { model.sendPedalboardControlTrigger( props.instanceId, "stop", @@ -394,12 +459,12 @@ export default function ToobPlayerControl( } function loopButtonText() { if (start === 0 && !loopEnable) { - return "Set loop"; + return "Set loop"; } else if (loopEnable) { - return `${formatDuration(start)} [${formatDuration(loopStart)} - ${formatDuration(loopEnd)}]`; + return `${formatTimeCompact(timebase, sampleRate, start)} [${formatTimeCompact(timebase, sampleRate, loopStart)} - ${formatTimeCompact(timebase, sampleRate, loopEnd)}]`; } else { - return `Start: ${formatDuration(start)}`; + return `Start: ${formatTimeCompact(timebase, sampleRate, start)}`; } } function getUiFileProperty(uri: string): UiFileProperty { @@ -415,6 +480,39 @@ export default function ToobPlayerControl( } throw "FileProperty not found."; } + + function onLoopPropertyChanged(loopSettingsJson: string) { + try { + let atomObject = JSON.parse(loopSettingsJson); + let loopParameters: LoopParameters = atomObject.loopParameters as LoopParameters; + let newTimebase: Timebase | undefined = atomObject.timebase as (Timebase | undefined);; + if (newTimebase !== undefined) { + if (!timebaseEqual(timebase, newTimebase)) { + setTimebase(newTimebase); + } + setLoopParameters(loopParameters); + setLoopEnable(loopParameters.loopEnable); + setStart(loopParameters.start); + setLoopStart(loopParameters.loopStart); + setLoopEnd(loopParameters.loopEnd); + } else { + throw new Error("Invalid loop settings."); + } + } catch (e) { + console.warn("Unable to parse loop settings."); + setTimebase( + { + units: TimebaseUnits.Seconds, + tempo: 120.0, + timeSignature: { numerator: 4, denominator: 4 } + }) + setLoopEnable(false); + setStart(0.0); + setLoopStart(0.0); + setLoopEnd(0.0); + } + + } function onNextTrack() { model.getNextAudioFile(audioFile) .then((file) => { @@ -441,17 +539,11 @@ export default function ToobPlayerControl( }); } function OnSeek(value: number) { - setDragging(true); model.setPatchProperty(props.instanceId, Player__seek, value) .then(() => { - setPosition(value); - setDragging(false); }).catch((e) => { console.warn("Seek error. " + e.toString()); - setPosition(value); - setDragging(false); }); - setSliderValue(value); } function onStateChanged(value: State) { setServerConnected(value === State.Ready); @@ -461,7 +553,7 @@ export default function ToobPlayerControl( if (model.state.get() !== State.Ready) { // wait for it. return () => { - model.state.removeOnChangedHandler + model.state.removeOnChangedHandler(onStateChanged); } } let durationHandle = model.monitorPort(props.instanceId, "duration", 1.0 / 15, @@ -474,27 +566,7 @@ export default function ToobPlayerControl( setPosition(value); } ); - // let startHandle = model.monitorPort(props.instanceId, "start", 1.0, - // (value) => { - // setStart(value); - // } - // ); - // let loopStartHandle = model.monitorPort(props.instanceId, "loopStart", 1.0, - // (value) => { - // setLoopStart(value); - // } - // ); - // let loopEnableHandle = model.monitorPort(props.instanceId, "loopEnable", 1.0, - // (value) => { - // setLoopEnable(value != 0); - // } - // ); - // let loopEndHandle = model.monitorPort(props.instanceId, "loopEnd", 1.0, - // (value) => { - // setLoopEnd(value); - // } - // ); - + let pluginStateHandle = model.monitorPort(props.instanceId, "state", 1.0 / 1000.0, (value) => { setPluginState(value); @@ -507,44 +579,81 @@ export default function ToobPlayerControl( if (typeof (atomObject) === "object") { let path = atomObject.value; onAudioFileChanged(path); - } else if (typeof(atomObject) === "string") - { + } else if (typeof (atomObject) === "string") { onAudioFileChanged(atomObject as string); } } + ); + let loopPropertyHandle = model.monitorPatchProperty( + props.instanceId, + LOOP_PROPERTY_URI, + (instanceId: number, propertyUri: string, atomObject: any) => { + if (typeof (atomObject) === "string") { + onLoopPropertyChanged(atomObject as string); + } + } + ); model.getPatchProperty(props.instanceId, AUDIO_FILE_PROPERTY_URI) .then((o) => { let path = o.value; onAudioFileChanged(path); + }); + model.getPatchProperty(props.instanceId, LOOP_PROPERTY_URI) + .then((o) => { + onLoopPropertyChanged(o as string); }) return () => { model.state.removeOnChangedHandler(onStateChanged); model.unmonitorPort(durationHandle); - model.unmonitorPort(positionHandle); model.unmonitorPort(pluginStateHandle); + model.unmonitorPort(positionHandle); // model.unmonitorPort(loopEnableHandle); // model.unmonitorPort(startHandle); // model.unmonitorPort(loopStartHandle); // model.unmonitorPort(loopEndHandle); model.cancelMonitorPatchProperty(filePropertyHandle); + model.cancelMonitorPatchProperty(loopPropertyHandle); }; }, [serverConnected] ); - const effectivePosition = - (pluginState !== PluginState.Idle) ? Math.min(position, duration) : 0.0; const titleLine = title !== "" ? title : pathFileNameOnly(audioFile); - const albumLine = getAlbumLine(album,artist,albumArtist); + const albumLine = getAlbumLine(album, artist, albumArtist); const paused = (pluginState === PluginState.Idle - || pluginState === PluginState.CuePlayPaused - || pluginState === PluginState.Paused); + || pluginState === PluginState.CuePlayingThenPause + || pluginState === PluginState.Paused + || pluginState === PluginState.Error); + function handlePreview() { + if (paused) { + model.sendPedalboardControlTrigger( + props.instanceId, + "play", + 1 + ); + } else { + model.sendPedalboardControlTrigger( + props.instanceId, + "stop", + 1 + ); + } + } + function handleCancelPlaying() { + if (!paused) { + model.sendPedalboardControlTrigger( + props.instanceId, + "stop", + 1 + ); + } + } function LinearMixPanel() { return ( @@ -573,84 +682,55 @@ export default function ToobPlayerControl( function SliderCluster() { return ( -
- { - 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', - }), - })} - /> +
+
+ { + }} + onValueChanged={(value) => { + OnSeek(value); + }} + /> +
- {formatDuration(dragging ? sliderValue : effectivePosition)} - - {formatDuration(duration)} + > + + {loopButtonText() + } + +
-
+
); } @@ -742,10 +822,12 @@ export default function ToobPlayerControl( {showLoopDialog && ( { handlePreview(); }} + value={loopParameters} + onCancelPlaying= {()=> { handleCancelPlaying();}} timebase={timebase} onTimebaseChange={(newTimebase) => { setTimebase(newTimebase); @@ -759,50 +841,23 @@ export default function ToobPlayerControl( onClose={() => { setShowLoopDialog(false); }} - onSetLoop={(start, loopEnable,loopStart, loopEnd) => { - - let loop: LoopParameters = { start: start, - loopEnable: loopEnable, - loopStart: loopStart, - loopEnd: loopEnd }; - + onSetLoop={(loop: LoopParameters) => { let loopSettings = { timebase: timebase, loopParameters: loop }; model.setPatchProperty( props.instanceId, - "http://two-play.com/plugins/toob-player#loopSettings", - loopSettings.toString()); + LOOP_PROPERTY_URI, + JSON.stringify(loopSettings)); - 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 - // ); + setStart(loop.start); + setLoopEnable(loop.loopEnable); + setLoopStart(loop.loopStart); + setLoopEnd(loop.loopEnd); }} - start={start} - loopStart={loopStart} - loopEnable={loopEnable} - loopEnd={loopEnd} duration={duration} - /> + /> )} {showFileDialog && ( void, onUploaded: (fileName: string) => void, uploadPage: string, - fileProperty: UiFileProperty + fileProperty: UiFileProperty, + isTracksDirectory: boolean }; @@ -251,7 +277,15 @@ export default class UploadFileDialog extends ResizeResponsiveComponent