Toob Player

This commit is contained in:
Robin E. R. Davies
2025-06-15 16:41:11 -04:00
parent 6ea45f46df
commit 13883f91ab
26 changed files with 1314 additions and 547 deletions
+21 -1
View File
@@ -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,
}
Binary file not shown.
+110 -52
View File
@@ -43,6 +43,41 @@ namespace fs = std::filesystem;
static constexpr size_t INVALID_INDEX = std::numeric_limits<size_t>::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<std::string> 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;
@@ -98,8 +133,7 @@ namespace
public:
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<DbFileInfo> 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<DbFileInfo> UpdateDbFiles();
void DbSetThumbnailType(
int64_t idFile,
@@ -156,7 +188,6 @@ namespace
void UpdateMetadata(DbFileInfo *dbFile);
static constexpr int DB_VERSION = 1;
std::filesystem::path GetFolderFile() const;
};
}
@@ -165,7 +196,6 @@ AudioDirectoryInfo::Ptr AudioDirectoryInfo::Create(const std::filesystem::path &
return std::make_shared<AudioDirectoryInfoImpl>(path, indexPath);
}
std::vector<DbFileInfo> AudioDirectoryInfoImpl::QueryTracks()
{
if (audioFilesDb)
@@ -264,7 +294,18 @@ std::vector<DbFileInfo> 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<DbFileInfo> 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<std::filesystem::path> 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<uint8_t> thumbnailData(
(std::istreambuf_iterator<char>(thumbnailStream)),
std::istreambuf_iterator<char>());
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);
+1
View File
@@ -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);
}
+11
View File
@@ -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<std::recursive_mutex> lock(mutex);
return storage.CopyFilePropertyFile(oldRelativePath, newRelativePath, uiFileProperty,overwrite);
}
void PiPedalModel::DeleteSampleFile(const std::filesystem::path &fileName)
{
std::lock_guard<std::recursive_mutex> lock(mutex);
+1
View File
@@ -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);
+25
View File
@@ -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;
+68 -2
View File
@@ -1705,6 +1705,13 @@ static void AddTracksToResult(
std::set<std::string> 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
{
+6
View File
@@ -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);
};
+8 -3
View File
@@ -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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 11 KiB

+51 -2
View File
@@ -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",
@@ -120,6 +162,14 @@ const theme = createTheme(
},
MuiButton: {
styleOverrides: {
root: {
'& .MuiTouchRipple-root': {
borderRadius: 'inherit',
},
'& .MuiTouchRipple-ripple': {
transform: 'scale(1.9)!important',
}
},
containedPrimary: {
borderRadius: '9999px',
paddingLeft: "16px", paddingRight: "16px",
@@ -178,8 +228,7 @@ const App = (class extends React.Component {
super(props);
this.state = {
};
if (!App.virtualKeyboardHandler)
{
if (!App.virtualKeyboardHandler) {
App.virtualKeyboardHandler = new VirtualKeyboardHandler();
}
}
+1
View File
@@ -43,3 +43,4 @@ input[type=number] {
+49
View File
@@ -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 title={
(
<Typography variant="caption">{tooltip || extra['aria-label']}</Typography>
)
}
placement="top-start" arrow
enterDelay={1500} enterNextDelay={1500}
>
<Button {...extra} style={style} />
</Tooltip>
);
}
export default ButtonEx;
+156
View File
@@ -0,0 +1,156 @@
/*
* 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, {useEffect} from "react";
import Slider,{ SliderProps } from "@mui/material/Slider";
import Typography from "@mui/material/Typography";
import { PiPedalModelFactory,State } from "./PiPedalModel";
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}`;
}
export interface ControlSliderProps extends SliderProps {
instanceId: number;
controlKey: string;
duration: number;
onPreviewValue: (value: number) => void;
onValueChanged: (value: number) => void; // Callback when the value changes
style?: React.CSSProperties;
}
function ControlSlider(props: ControlSliderProps) {
const { style,instanceId, controlKey, duration,
onPreviewValue, onValueChanged, ...extras} = props;
const model = PiPedalModelFactory.getInstance();
let [sliderValue, setSliderValue] = React.useState(0);
let [effectiveValue, setEffectiveValue] = React.useState(0);
let [dragging, setDragging] = React.useState(false);
let [serverConnected,setServerConnected] = React.useState(model.state.get() === State.Ready);
const handleStateChanged = (state: State) => {
setServerConnected(state === State.Ready);
};
useEffect(() => {
model.state.addOnChangedHandler(handleStateChanged);
if (model.state.get() !== State.Ready) {
return () => {
model.state.removeOnChangedHandler(handleStateChanged);
};
}
let handle = model.monitorPort(instanceId, controlKey, 1.0/15.0,(value: number) => {
setSliderValue(value);
});
return () => {
model.state.removeOnChangedHandler(handleStateChanged);
model.unmonitorPort(handle);
};
}, [instanceId,controlKey,serverConnected]);
return (
<div style={{ display: "flex", flexFlow: "column nowrap" }}>
<Slider
{...extras}
value={dragging ? effectiveValue : sliderValue}
min={0}
step={1}
max={duration}
onChange={(_, val: any) => {
let v = parseFloat(val);
setDragging(true);
setEffectiveValue(v);
onPreviewValue(v);
}}
onChangeCommitted={(_, val: any) => {
let v = parseFloat(val);
setDragging(false);
setSliderValue(v);
onValueChanged(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.0s',
transitionProperty: 'none',
'&::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,
},
'& .MuiSlider-track': {
transition: '0.0s',
transitionProperty: 'none',
},
...t.applyStyles('dark', {
color: '#fff',
}),
})}
/>
<div
style={{
flex: "0 0 auto",
width: "100%",
display: 'flex',
alignItems: 'top',
justifyContent: 'space-between',
marginTop: -4,
}}
>
<Typography variant="caption">{formatDuration(dragging ? effectiveValue : sliderValue)}</Typography>
<Typography variant="caption">{formatDuration(duration)}</Typography>
</div>
</div>
);
}
export default ControlSlider;
+119 -18
View File
@@ -33,6 +33,7 @@ import MoreIcon from '@mui/icons-material/MoreVert';
import { PiPedalModel, PiPedalModelFactory, FileEntry, BreadcrumbEntry, FileRequestResult } from './PiPedalModel';
import { isDarkMode } from './DarkMode';
import Button from '@mui/material/Button';
import ButtonEx from './ButtonEx';
import FileUploadIcon from '@mui/icons-material/FileUpload';
import FileDownloadIcon from '@mui/icons-material/FileDownload';
import AudioFileIcon from '@mui/icons-material/AudioFile';
@@ -44,6 +45,7 @@ import DialogActions from '@mui/material/DialogActions';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import IconButton from '@mui/material/IconButton';
import IconButtonEx from './IconButtonEx';
import OldDeleteIcon from './OldDeleteIcon';
import Toolbar from '@mui/material/Toolbar';
import WithStyles from './WithStyles';
@@ -77,13 +79,14 @@ const styles = (theme: Theme) => createStyles({
const audioFileExtensions: { [name: string]: boolean } = {
".wav": true,
".flac": true,
".mp3": true,
".m4a": true,
".ogg": true,
".aac": true,
".au": true,
".snd": true,
".mid": true,
".rmi": true,
".mp3": true,
".mp4": true,
".aif": true,
".aifc": true,
@@ -146,10 +149,16 @@ export interface FilePropertyDialogState {
columnWidth: number;
openUploadFileDialog: boolean;
openConfirmDeleteDialog: boolean;
confirmCopyDialogState: {
fileName: string;
oldFilePath: string;
newFilePath: string;
} | null;
menuAnchorEl: null | HTMLElement;
newFolderDialogOpen: boolean;
renameDialogOpen: boolean;
moveDialogOpen: boolean;
copyDialogOpen: boolean;
initialSelection: string;
};
@@ -173,6 +182,12 @@ export default withStyles(
isTracksDirectory(): boolean {
return this.state.currentDirectory.startsWith("/var/pipedal/audio_uploads/shared/audio/Tracks");
}
isFolderArtwork(filePath: string): boolean {
if (!this.isTracksDirectory()) return false;
let extension = pathExtension(filePath);
return extension === ".png" || extension === ".jpg" || extension === ".jpeg";
}
constructor(props: FilePropertyDialogProps) {
super(props);
@@ -202,10 +217,12 @@ export default withStyles(
isProtectedDirectory: true,
openUploadFileDialog: false,
openConfirmDeleteDialog: false,
confirmCopyDialogState: null,
menuAnchorEl: null,
newFolderDialogOpen: false,
renameDialogOpen: false,
moveDialogOpen: false,
copyDialogOpen: false,
initialSelection: this.props.selectedFile
};
this.requestScroll = true;
@@ -485,6 +502,7 @@ export default withStyles(
newFolderDialogOpen: false,
renameDialogOpen: false,
moveDialogOpen: false,
copyDialogOpen: false,
navDirectory: navDirectory
});
this.requestFiles(navDirectory)
@@ -531,8 +549,10 @@ export default withStyles(
}
this.requestScroll = true;
if (!fileEntry.isDirectory) {
if (!this.isFolderArtwork(fileEntry.pathname)) {
this.props.onApply(this.props.fileProperty, fileEntry.pathname);
}
}
this.setState({
selectedFile: fileEntry.pathname,
selectedFileIsDirectory: fileEntry.isDirectory,
@@ -814,6 +834,8 @@ export default withStyles(
let needsDivider = canMove || canRename || canReorder;
let trackPosition = 0;
let compactVertical = this.state.windowHeight < 700;
let canSelectFile = this.state.hasSelection && !this.isFolderArtwork(this.state.selectedFile);
return this.props.open &&
(
@@ -886,7 +908,8 @@ export default withStyles(
{this.props.fileProperty.label}
</Typography>
<IconButton
<IconButtonEx
tooltip="New folder"
aria-label="new folder"
edge="end"
color="inherit"
@@ -895,10 +918,11 @@ export default withStyles(
disabled={protectedDirectory}
>
<CreateNewFolderIcon />
</IconButton>
</IconButtonEx>
<IconButton
<IconButtonEx
tooltip="More..."
aria-label="display more actions"
edge="end"
color="inherit"
@@ -908,7 +932,7 @@ export default withStyles(
>
<MoreIcon />
</IconButton>
</IconButtonEx>
<Menu
id="menu-appbar"
anchorEl={this.state.menuAnchorEl}
@@ -927,6 +951,7 @@ export default withStyles(
<MenuItem onClick={() => { this.handleMenuClose(); this.onNewFolder(); }}>New folder</MenuItem>
{(needsDivider) && (<Divider />)}
{canMove && (<MenuItem onClick={() => { this.handleMenuClose(); this.onMove(); }}>Move</MenuItem>)}
{canMove && (<MenuItem onClick={() => { this.handleMenuClose(); this.onCopy(); }}>Copy</MenuItem>)}
{canRename && this.hasSelectedFileOrFolder() && !protectedItem && (<MenuItem onClick={() => {
this.handleMenuClose(); this.onRename();
}}>Rename</MenuItem>)}
@@ -1013,7 +1038,7 @@ export default withStyles(
<DraggableButtonBase key={value.pathname}
longPressDelay={this.state.reordering ? 0 : -1}
data-position={dataPosition}
data-fileName={
data-pathname={
value.metadata ? value.metadata.fileName : null
}
@@ -1047,10 +1072,20 @@ export default withStyles(
display: "flex", flexFlow: "row nowrap", textOverflow: "ellipsis",
justifyContent: "start", alignItems: "center", width: "100%", height: "100%"
}}>
{
this.isFolderArtwork(value.pathname) ? (
<img
onDragStart={(e) => { e.preventDefault(); }}
src={this.getTrackThumbnail(value)}
style={{ width: 24, height: 24, margin: 20, borderRadius: 4 }} />
):(
<img
onDragStart={(e) => { e.preventDefault(); }}
src={this.getTrackThumbnail(value)}
style={{ width: 48, height: 48, margin: 8, borderRadius: 4 }} />
)
}
<div style={{
flex: "1 1 1px", display: "flex", flexFlow: "column nowrap",
marginLeft: 8,
@@ -1115,23 +1150,24 @@ export default withStyles(
<DialogActions style={{ justifyContent: "stretch" }}>
{this.state.windowWidth > 500 ? (
<div style={{ display: "flex", width: "100%", alignItems: "center", flexFlow: "row nowrap" }}>
<IconButton style={{ visibility: (this.state.hasSelection ? "visible" : "hidden") }} aria-label="delete" component="label" color="primary"
<IconButtonEx style={{ visibility: (this.state.hasSelection ? "visible" : "hidden") }} aria-label="delete" component="label" color="primary"
tooltip="Delete selected file or folder"
disabled={!this.state.hasSelection || this.state.selectedFile === "" || protectedItem}
onClick={() => this.handleDelete()} >
<OldDeleteIcon fontSize='small' />
</IconButton>
</IconButtonEx>
<Button style={{ flex: "0 0 auto" }} aria-label="upload" variant="text" startIcon={<FileUploadIcon />}
<ButtonEx tooltip="Upload file" style={{ flex: "0 0 auto" }} aria-label="upload" variant="text" startIcon={<FileUploadIcon />}
onClick={() => { this.setState({ openUploadFileDialog: true }) }} disabled={protectedDirectory}
>
<div>Upload</div>
</Button>
</ButtonEx>
<Button style={{ flex: "0 0 auto" }} aria-label="upload" variant="text" startIcon={<FileDownloadIcon />}
<ButtonEx tooltip="Download file" style={{ flex: "0 0 auto" }} aria-label="upload" variant="text" startIcon={<FileDownloadIcon />}
onClick={() => { this.handleDownloadFile(); }} disabled={protectedDirectory || !this.state.hasFileSelection}
>
<div>Download</div>
</Button>
</ButtonEx>
<div style={{ flex: "1 1 auto" }}>&nbsp;</div>
@@ -1144,7 +1180,8 @@ export default withStyles(
<Button variant="dialogPrimary" style={{ flex: "0 0 auto" }}
onClick={() => { this.openSelectedFile(); }}
disabled={(!this.state.hasSelection) && this.state.selectedFile !== ""} aria-label="select"
disabled={(!canSelectFile)
} aria-label="select"
>
{okButtonText}
</Button>
@@ -1182,7 +1219,8 @@ export default withStyles(
<Button variant="dialogPrimary" style={{ flex: "0 0 auto" }}
onClick={() => { this.openSelectedFile(); }}
disabled={(!this.state.hasSelection) && this.state.selectedFile !== ""} aria-label="select"
disabled={!canSelectFile}
aria-label="select"
>
{okButtonText}
</Button>
@@ -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(
}}
/>
<OkCancelDialog open={this.state.confirmCopyDialogState !== null}
text={
"The target file already exists. Would you like to overwrite it?"}
okButtonText="Overwrite"
onOk={() => { this.handleConfirmCopy(); }}
onClose={() => {
this.setState({ confirmCopyDialogState: null });
}}
/>
{
this.state.newFolderDialogOpen && (
<RenameDialog open={this.state.newFolderDialogOpen} defaultName=""
@@ -1244,25 +1294,30 @@ export default withStyles(
)
}
{
this.state.moveDialogOpen && (
(this.state.moveDialogOpen || this.state.copyDialogOpen)
&& (
(
<FilePropertyDirectorySelectDialog
open={this.state.moveDialogOpen}
dialogTitle="Move to"
open={this.state.moveDialogOpen || this.state.copyDialogOpen}
dialogTitle={this.state.moveDialogOpen ? "Move to" : "Copy Link to"}
uiFileProperty={this.props.fileProperty}
defaultPath={this.getDefaultPath()}
selectedFile={this.state.selectedFile}
excludeDirectory={
this.isDirectory(this.state.selectedFile)
? this.state.selectedFile : ""}
onClose={() => { this.setState({ moveDialogOpen: false }); }}
onClose={() => { this.setState({ moveDialogOpen: false, copyDialogOpen: false }); }}
onOk={
(path) => {
this.setState({ moveDialogOpen: false });
this.setDefaultPath(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 {
+50
View File
@@ -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 title={
(
<Typography variant="caption">{tooltip || extra['aria-label'] }</Typography>
)
}
placement="top-start" arrow
enterDelay={1500} enterNextDelay={1500}
>
<IconButton {...extra} style={style} />
</Tooltip>
);
}
export default IconButtonEx;
+114 -96
View File
@@ -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;
@@ -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:
{
@@ -323,7 +328,6 @@ 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.
@@ -335,7 +339,9 @@ function TimeEdit(props: TimeEditProps) {
[props.value]);
React.useEffect(() => {
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<number | null>(null);
const [previewLoopStart, setPreviewLoopStart] = React.useState<number | null>(null);
const [previewLoopEnd, setPreviewLoopEnd] = React.useState<number | null>(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 (
<DialogEx
tag="LoopDialog"
onClose={props.onClose}
fullScreen={false}
onClose={() => {
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();
}}
>
<ArrowBackIcon style={{ width: 24, height: 24 }} />
</IconButton>
@@ -449,8 +485,8 @@ export default function LoopDialog(props: LoopDialogProps) {
<DialogContent style={{}}>
<div style={{
position: "relative",
display: "flex", justifyContent: "stretch", flexFlow: "column nowrap", marginLeft: 32, marginRight: 32,
marginBottom: 16
display: "flex", justifyContent: "stretch", flexFlow: "column nowrap", marginLeft: 24, marginRight: 24,
marginBottom: 8
}}>
<Typography display="block" variant="body1" gutterBottom style={{}}>
@@ -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)}
/>
<TimeEdit variant="standard" spellCheck="false"
value={previewStartValue ? previewStartValue : props.start}
value={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);
}
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) {
<FormControlLabel
labelPlacement="start"
style={{ marginLeft: 0 }}
control={<Checkbox checked={props.loopEnable}
control={<Checkbox checked={loopEnable}
onChange={(e, checked) => {
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) {
<SliderWithPreview
style={{
width: "100%",
opacity: props.loopEnable ? 1 : 0.4,
opacity: loopEnable ? 1 : 0.4,
}}
disabled={!props.loopEnable}
disabled={!loopEnable}
value={
previewLoopStart != null ?
[previewLoopStart as number, previewLoopEnd as number]
: [props.loopStart, props.loopEnd]}
[loopStart, 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]);
}
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();
}}
/>
<div style={{ display: "flex", justifyContent: "space-evenly", columnGap: 8 }}>
<TimeEdit variant="standard" spellCheck="false"
value={previewLoopStart ? previewLoopStart : props.loopStart}
disabled={!props.loopEnable}
value={loopStart}
disabled={!loopEnable}
max={props.duration}
timebase={props.timebase}
sampleRate={props.sampleRate}
style={{
maxWidth: 120, textAlign: "center", opacity: props.loopEnable ? 1 : 0.4,
maxWidth: 120, textAlign: "center", opacity: 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);
}
setLoopStart(val);
cancelPlaying();
}}
/>
<TimeEdit variant="standard" spellCheck="false"
max={props.duration}
value={previewLoopEnd ? previewLoopEnd : props.loopEnd}
value={loopEnd}
sampleRate={props.sampleRate}
disabled={!props.loopEnable}
disabled={!loopEnable}
timebase={props.timebase}
style={{
maxWidth: 120, textAlign: "center", opacity: props.loopEnable ? 1 : 0.4,
maxWidth: 120, textAlign: "center",
opacity: 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);
}
setLoopEnd(val);
cancelPlaying();
}}
/>
</div>
<Button variant="dialogSecondary" style={{ alignSelf: "end", marginTop: 16, width: 120 }}
startIcon={
props.playing ? (<StopIcon />) : (<PlayArrow />)
}
onClick={() => {
commitResults();
props.onPreview();
}}>
Preview
</Button>
</div>
</DialogContent>
+16 -12
View File
@@ -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 =
}} >
<div style={{ flex: "0 0 auto", width: this.state.splitControlBar? undefined: 80 }} >
<div style={{ display: bypassVisible ? "block" : "none", width: this.state.splitControlBar? undefined: 80 }} >
<Tooltip title="Bypass" placement="top-start" arrow enterDelay={1500} enterNextDelay={1500}>
<Switch color="secondary" checked={bypassChecked} onChange={this.handleEnableCurrentItemChanged} />
</Tooltip>
</div>
</div>
{
@@ -541,9 +544,9 @@ export const MainPage =
{this.props.enableStructureEditing && (
<div style={{ flex: "0 0 auto", display: "flex", flexFlow: "row nowrap",alignItems: "center" }}>
<div style={{ flex: "0 0 auto", display: (canInsert || canAppend) ? "block" : "none", paddingRight: 8 }}>
<IconButton onClick={(e) => { this.onAddClick(e) }} size="large">
<IconButtonEx tooltip="Add pedal slot" onClick={(e) => { this.onAddClick(e) }} size="large">
<AddIcon style={{ height: 24, width: 24, fill: this.props.theme.palette.text.primary, opacity: 0.6 }} />
</IconButton>
</IconButtonEx>
<Menu
id="add-menu"
anchorEl={this.state.addMenuAnchorEl}
@@ -560,17 +563,18 @@ export const MainPage =
</Menu>
</div>
<div style={{ flex: "0 0 auto", display: canDelete ? "block" : "none", paddingRight: 8 }}>
<IconButton
<IconButtonEx tooltip="Delete pedal"
onClick={() => { this.onDeletePedal(pedalboardItem?.instanceId ?? -1) }}
size="large">
<OldDeleteIcon style={{ height: 24, width: 24, fill: this.props.theme.palette.text.primary, opacity: 0.6 }} />
</IconButton>
</IconButtonEx>
</div>
<div style={{ flex: "0 0 auto" }}>
<Button
<ButtonEx
variant="contained"
color="primary"
size="small"
tooltip="Load plugin"
onClick={this.onLoadClick}
disabled={this.state.selectedPedal === -1 || (!canLoad) || (this.getSelectedPedalboardItem()?.isSplit() ?? true)}
startIcon={<InputIcon />}
@@ -580,21 +584,21 @@ export const MainPage =
}}
>
Load
</Button>
</ButtonEx>
</div>
<div style={{ flex: "0 0 auto" }}>
<IconButton
<IconButtonEx tooltip="MIDI bindings"
onClick={(e) => { this.handleMidiConfiguration(instanceId); }}
size="large">
<MidiIcon style={{ height: 24, width: 24, fill: this.props.theme.palette.text.primary, opacity: 0.6 }} />
</IconButton>
</IconButtonEx>
</div>
<div style={{ flex: "0 0 auto" }}>
<IconButton
<IconButtonEx tooltip="Snapshots"
onClick={(e) => { this.setState({ snapshotDialogOpen: true }); }}
size="large">
{this.snapshotIcon(this.props.theme, this.state.selectedSnapshot)}
</IconButton>
</IconButtonEx>
</div>
</div>
)}
+30
View File
@@ -2697,6 +2697,36 @@ export class PiPedalModel //implements PiPedalModel
});
});
}
copyFilePropertyFile(
oldRelativePath: string,
newRelativePath: string,
uiFileProperty: UiFileProperty,
overwrite: boolean
): Promise<string> {
return new Promise<string>((resolve, reject) => {
let ws = this.webSocket;
if (!ws) {
resolve("");
return;
}
ws.request<string>(
"copyFilePropertyFile",
{
oldRelativePath: oldRelativePath,
newRelativePath: newRelativePath,
uiFileProperty: uiFileProperty,
overwrite: overwrite
}
)
.then((newPath) => {
resolve(newPath);
})
.catch((err) => {
reject(err);
});
});
}
setWifiConfigSettings(wifiConfigSettings: WifiConfigSettings): Promise<void> {
let result = new Promise<void>((resolve, reject) => {
+17 -3
View File
@@ -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,17 +700,20 @@ const PluginControl =
if (control.isOnOffSwitch()) {
// normal gray unchecked state.
return (
<ControlTooltip uiControl={control}>
<Switch checked={value !== 0} color="primary"
onChange={(event) => {
this.onCheckChanged(event.target.checked);
}}
/>
</ControlTooltip>
);
}
if (control.isAbToggle()) {
let classes = withStyles.getClasses(this.props);
// unchecked color is not gray.
return (
<ControlTooltip uiControl={control}>
<Switch checked={value !== 0} color="primary"
onChange={(event) => {
this.onCheckChanged(event.target.checked);
@@ -719,9 +723,11 @@ const PluginControl =
}}
style={{ color: this.props.theme.palette.primary.main }}
/>
</ControlTooltip>
);
} else {
return (
<ControlTooltip uiControl={control}>
<Select variant="standard"
ref={this.selectRef}
value={control.clampSelectValue(value)}
@@ -738,6 +744,7 @@ const PluginControl =
))}
</Select>
</ControlTooltip>
);
}
}
@@ -919,12 +926,10 @@ const PluginControl =
alignSelf: "stretch", marginBottom: 8, marginLeft: isSelect ? 8 : 0, marginRight: 0
}}>
<ControlTooltip uiControl={control} >
<Typography variant="caption" display="block" noWrap style={{
width: "100%",
textAlign: isSelect ? "left" : "center"
}}> {isButton ? "\u00A0" : control.name}</Typography>
</ControlTooltip>
</div>
{/* CONTROL SECTION */}
@@ -933,6 +938,8 @@ const PluginControl =
{isButton ?
(
control.name.length !== 1 ? (
<ControlTooltip uiControl={control}>
<Button variant="contained" color="primary" size="small"
onMouseDown={
(evt) => { this.handleButtonMouseDown(buttonStyle); }
@@ -967,8 +974,10 @@ const PluginControl =
>
{control.name}
</Button>
</ControlTooltip>
) : (
<ControlTooltip uiControl={control}>
<Button variant="contained" color="primary" size="small"
onMouseDown={
(evt) => { this.handleButtonMouseDown(buttonStyle); }
@@ -1008,6 +1017,7 @@ const PluginControl =
>
{androidEmoji(control.name)}
</Button>
</ControlTooltip>
)
)
@@ -1016,6 +1026,7 @@ const PluginControl =
)
: (isGraphicEq) ? (
<div style={{ flex: "0 1 auto" }}>
<ControlTooltip uiControl={control}>
<GraphicEqCtl
imgRef={this.imgRef}
position={this.getEqPosition()}
@@ -1030,9 +1041,11 @@ const PluginControl =
/>
</ControlTooltip>
</div>
) : (
<div style={{ flex: "0 1 auto" }}>
<ControlTooltip uiControl={control}>
<DialIcon ref={this.imgRef}
style={{
overscrollBehavior: "none", touchAction: "none", fill: dialColor,
@@ -1044,6 +1057,7 @@ const PluginControl =
onDrag={this.onDrag}
/>
</ControlTooltip>
</div>
)
}
+8 -1
View File
@@ -472,7 +472,6 @@ const PluginControlView =
throw new PiPedalStateError("Missing control value.");
}
return ((
<PluginControl key={uiControl.symbol} instanceId={this.props.instanceId} uiControl={uiControl} value={controlValue.value}
onChange={(value: number) => { this.onControlValueChanged(controlValue!.key, value) }}
onPreviewChange={(value: number) => { this.onPreviewChange(controlValue!.key, value) }}
@@ -480,6 +479,14 @@ const PluginControlView =
/>
));
// return ((
// <PluginControl key={uiControl.symbol} instanceId={this.props.instanceId} uiControl={uiControl} value={controlValue.value}
// onChange={(value: number) => { this.onControlValueChanged(controlValue!.key, value) }}
// onPreviewChange={(value: number) => { this.onPreviewChange(controlValue!.key, value) }}
// requestIMEEdit={(uiControl: any, value: any) => this.requestImeEdit(uiControl, value)}
// />
// ));
}
+19 -15
View File
@@ -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 (
<div>
<IconButton
<IconButtonEx tooltip="Plugin info"
style={{ display: (props.plugin_uri !== "") ? "inline-flex" : "none" }}
onClick={handleClickOpen}
size="large">
<InfoOutlinedIcon className={classes.icon} color='inherit' />
</IconButton>
</IconButtonEx>
{open && (
<DialogEx tag="info" onClose={handleClose} open={open} fullWidth maxWidth="md"
onEnterKey={handleClose}
>
<MuiDialogTitle >
<div style={{ display: "flex", flexDirection: "row", alignItems: "start", flexWrap: "nowrap" }}>
<div style={{ flex: "0 0 auto", marginRight: 16 }}>
<PluginIcon pluginType={plugin.plugin_type} offsetY={3} />
<div style={{ display: "flex", flexDirection: "row", alignItems: "center", flexWrap: "nowrap" }}>
<IconButtonEx
edge="start"
color="inherit"
tooltip="Back"
aria-label="back"
style={{ opacity: 0.6 }}
onClick={() => { handleClose()}}
>
<ArrowBackIcon style={{ width: 24, height: 24 }} />
</IconButtonEx>
<div style={{ flex: "0 0 auto", marginLeft: 16,marginRight: 8, position: "relative", top: 3 }}>
<PluginIcon pluginType={plugin.plugin_type} />
</div>
<div style={{ flex: "1 1 auto" }}>
<Typography variant="h6">{plugin.name}</Typography>
</div>
<IconButton
aria-label="close"
className={classes.closeButton}
onClick={() => handleClose()}
style={{ flex: "0 0 auto" }}
size="large">
<CloseIcon />
</IconButton>
</div>
</MuiDialogTitle>
<PluginInfoDialogContent dividers style={{ width: "100%", maxHeight: "80%", overflowX: "hidden" }}>
+5 -3
View File
@@ -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 (
<div >
<IconButton onClick={(e)=> this.handlePresetMenuClick(e)} size="large">
<IconButtonEx
tooltip="Plugin presets"
onClick={(e)=> this.handlePresetMenuClick(e)} size="large">
<PluginPresetsIcon className={classes.pluginIcon}/>
</IconButton>
</IconButtonEx>
<Menu
id="edit-plugin-presets-menu"
anchorEl={this.state.presetsMenuAnchorRef}
+216 -161
View File
@@ -26,8 +26,7 @@ import { styled } from '@mui/material/styles';
import LoopDialog from './LoopDialog';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import Slider from '@mui/material/Slider';
import ButtonEx from './ButtonEx';
import IconButton from '@mui/material/IconButton';
import Pause from '@mui/icons-material/Pause';
import PlayArrow from '@mui/icons-material/PlayArrow';
@@ -44,19 +43,23 @@ import { Divider } from '@mui/material';
import useWindowSize from './UseWindowSize';
import { getAlbumArtUri } from './AudioFileMetadata';
import RepeatIcon from '@mui/icons-material/Repeat';
import { LoopParameters, TimebaseUnits } from './Timebase';
import Timebase, { LoopParameters, TimebaseUnits } from './Timebase';
import ControlSlider from './ControlSlider';
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 LOOP_PROPERTY_URI = "http://two-play.com/plugins/toob-player#loop";
class PluginState {
// Must match PluginState values in ToobPlayer.hpp
static Idle = 0.0;
static CuePlaying = 1;
static CuePlayPaused = 2.0;
static Pausing = 3;
static Paused = 4;
static Playing = 5;
static Error = 6;
// Must match ProcessorState values in Lv2AudioFileProcessor.hpp
// Not an enum because it has to be json serializable.
static Idle = 0;
static Recording = 1;
static StoppingRecording = 2;
static CuePlayingThenPlay = 3;
static CuePlayingThenPause = 4;
static Paused = 5;
static Playing = 6;
static Error = 7;
};
const useWallpaper = false;
@@ -92,6 +95,71 @@ const WallPaper = styled('div')({
},
});
function tidyHundredths(value: number): string {
if (value === 0) {
return "";
} else if (value % 10 == 0) {
return `.${(value / 10).toString()}`;
} else {
return `.${value.toString()}`;
}
}
export function formatTimeCompact(timebase: Timebase, sampleRate: number, seconds: number): string {
switch (timebase.units) {
case TimebaseUnits.Samples:
{
return Math.round(seconds * sampleRate).toString();
}
break;
case TimebaseUnits.Seconds:
{
const t = Math.round(seconds * 100);
let hundredths = t % 100;
let secs = Math.floor(t / 100);
let minutes = Math.floor(secs / 60);
let hours = Math.floor(minutes / 60);
minutes = minutes % 60;
secs = secs % 60;
if (hours == 0) {
return `${minutes}:${secs.toString().padStart(2, '0')}${tidyHundredths(hundredths)}`;
} else {
return `${hours}:${minutes}:${secs.toString().padStart(2, '0')}${tidyHundredths(hundredths)}`;
}
break;
}
case TimebaseUnits.Beats:
{
let t = Math.round(seconds * timebase.tempo / 60.0 * 100.0);
let hundredths = t % 100;
let beats = Math.floor(t / 100.0);
let bars = Math.floor(beats / timebase.timeSignature.numerator);
let beat = (beats - bars * timebase.timeSignature.numerator);
return `${bars + 1}:${(beat + 1)}${tidyHundredths(hundredths)}`;
}
break;
default:
throw new Error("Unsupported timebase units");
}
}
function timebaseEqual(a: Timebase, b: Timebase): boolean {
if (a.units !== b.units) {
return false;
}
if (a.tempo !== b.tempo) {
return false;
}
if (a.timeSignature.numerator !== b.timeSignature.numerator) {
return false;
}
if (a.timeSignature.denominator !== b.timeSignature.denominator) {
return false;
}
return true;
}
function getAlbumLine(album: string, artist: string, albumArtist: string): string {
if (artist === "") {
artist = albumArtist;
@@ -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<Timebase>(
{
units: TimebaseUnits.Seconds,
tempo: 120.0,
timeSignature: { numerator: 4, denominator: 4 }
});
const [loopParameters, setLoopParameters] = React.useState<LoopParameters>({
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 === "") {
@@ -298,7 +363,7 @@ export default function ToobPlayerControl(
<IconButton
style={{ opacity: (pluginState === PluginState.Idle ? 0.2 : 0.7) }}
onClick={() => {
if (position > 3) {
if (position > start + 3.0) {
model.sendPedalboardControlTrigger(
props.instanceId,
"stop",
@@ -397,9 +462,9 @@ export default function ToobPlayerControl(
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,26 +566,6 @@ 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) => {
@@ -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 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 (
<Box style={{ width: "100%" }} >
@@ -573,82 +682,53 @@ export default function ToobPlayerControl(
function SliderCluster() {
return (
<div style={{ display: "flex", flexFlow: "column nowrap" }}>
<Slider
value={dragging ? sliderValue : effectivePosition}
min={0}
step={1}
max={duration}
onChange={(_, val) => {
let v = val as number;
setPosition(v);
setSliderValue(v);
<div style={{ position: "relative", display: "flex", flexFlow: "column nowrap" }}>
<div style={{ marginBottom: 16 }}>
<ControlSlider
instanceId={props.instanceId}
controlKey="position"
duration={duration}
onPreviewValue={(value) => {
}}
onChangeCommitted={(e, value) => {
let v = value as number;
OnSeek(v);
onValueChanged={(value) => {
OnSeek(value);
}}
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',
}),
})}
/>
</div>
<div
style={{
flex: "0 0 auto",
position: "absolute",
width: "100%",
display: 'flex',
alignItems: 'top',
justifyContent: 'space-between',
marginTop: -4,
display: "flex", flexFlow: "row nowrap", alignItems: "center",
justifyContent: "center",
bottom: 0
}}
>
<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"
{/**
*
sx={{
'& .MuiTouchRipple-root': {
},
'& .MuiTouchRipple-ripple': {
transform: 'scale(1.9) !important',
}
}}
*/}
<ButtonEx tooltip="Loop Settings" variant="dialogSecondary"
style={{
flex: "0 1 auto", maxWidth: 200,
textTransform: "none", padding: "4px 8px"
}}
startIcon={(<RepeatIcon />)}
onClick={() => {
setShowLoopDialog(true);
}}
>
<Typography noWrap variant="caption">
{loopButtonText()
}
</Button>
<TinyText>{formatDuration(duration)}</TinyText>
</Typography>
</ButtonEx>
</div>
</div >
);
@@ -742,10 +822,12 @@ export default function ToobPlayerControl(
{showLoopDialog && (
<LoopDialog isOpen={showLoopDialog}
sampleRate={
model.jackConfiguration.get().sampleRate == 0?
96000 :
model.jackConfiguration.get().sampleRate
sampleRate
}
playing={!paused}
onPreview={() => { handlePreview(); }}
value={loopParameters}
onCancelPlaying= {()=> { handleCancelPlaying();}}
timebase={timebase}
onTimebaseChange={(newTimebase) => {
setTimebase(newTimebase);
@@ -759,48 +841,21 @@ 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}
/>
)}
+36 -2
View File
@@ -41,12 +41,38 @@ import CircularProgress from '@mui/material/CircularProgress';
// const BANK_EXTENSION = ".piBank";
// const PLUGIN_PRESETS_EXTENSION = ".piPluginPresets";
let 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.
];
function isCoverArtFile(fileName: string) : boolean {
// does COVER_ART_FILES contain the file name?
for (let coverArtFile of COVER_ART_FILES) {
if (fileName === coverArtFile) {
return true;
}
}
return false;
}
export interface UploadFileDialogProps {
open: boolean,
onClose: () => void,
onUploaded: (fileName: string) => void,
uploadPage: string,
fileProperty: UiFileProperty
fileProperty: UiFileProperty,
isTracksDirectory: boolean
};
@@ -251,7 +277,15 @@ export default class UploadFileDialog extends ResizeResponsiveComponent<UploadFi
private wantsFile(file: File | null) {
if (file === null) return false;
return this.props.fileProperty.wantsFile(file.name);
if (this.props.fileProperty.wantsFile(file.name))
{
return true;
}
if (this.props.isTracksDirectory) {
if (isCoverArtFile(file.name)) {
return true;
} }
return false;
}
private wantsTransfer(fileList: DataTransfer): boolean {