Toob Player UI

This commit is contained in:
Robin E. R. Davies
2025-06-07 21:52:23 -04:00
parent 82f94cb152
commit 6ea45f46df
29 changed files with 1860 additions and 319 deletions
+20 -2
View File
@@ -21,6 +21,7 @@
* SOFTWARE.
*/
import { pathConcat, pathParentDirectory } from "./FileUtils";
import { PiPedalModel } from "./PiPedalModel";
export class ThumbnailType {
@@ -39,6 +40,8 @@ export default class AudioFileMetadata {
this.title = o.title;
this.track = o.track;
this.album = o.album;
this.artist = o.artist;
this.albumArtist = o.albumArtist;
this.duration = o.duration;
this.thumbnailType = o.thumbnailType;
this.thumbnailFile = o.thumbnailFile;
@@ -47,16 +50,20 @@ export default class AudioFileMetadata {
return this;
}
fileName: string = "";
lastModified: number = 0;
lastModified: number = 0;
title: string = "";
track: number = 0;
album: string = "";
artist: string = "";
albumArtist: string = "";
duration: number = 0;
thumbnailType: ThumbnailType = new ThumbnailType();
thumbnailFile = "";
thumbnailLastModified = 0;
}
export function getAlbumArtUri(model: PiPedalModel, metadata: AudioFileMetadata | undefined, path: string): string {
let coverArtUri: string;
if (!metadata) {
@@ -65,10 +72,21 @@ export function getAlbumArtUri(model: PiPedalModel, metadata: AudioFileMetadata
if (metadata.thumbnailType === ThumbnailType.None) {
return "/img/missing_thumbnail.jpg";
} else if (metadata.thumbnailType === ThumbnailType.Folder) {
// Use the thumbnailFile to get the cover art.
let thumbnailPath = pathConcat(pathParentDirectory(path) ,metadata.thumbnailFile);
coverArtUri = model.varServerUrl + "Thumbnail"
+ "?ffile=" + encodeURIComponent(thumbnailPath)
+ "&t=" + metadata.thumbnailLastModified
+ "&w=240&h=240"
;
return coverArtUri;
} else {
// for embeed and unknown
coverArtUri = model.varServerUrl + "Thumbnail"
+ "?path=" + encodeURIComponent(path)
+ "&t=" + metadata.thumbnailLastModified
+ "&t=" + metadata.lastModified
+ "&w=240&h=240"
;
return coverArtUri;
+17
View File
@@ -35,6 +35,17 @@ export interface DraggableButtonBaseProps extends ButtonBaseProps {
interface Point {
x: number;
y: number;
};
function isValidPointer(e: React.PointerEvent): boolean {
if (e.pointerType === "mouse") {
return e.button === 0;
} else if (e.pointerType === "pen") {
return true;
} else if (e.pointerType === "touch") {
return true;
}
return false;
}
function screenToClient(element: HTMLElement, point: Point): Point {
@@ -81,6 +92,12 @@ export default function DraggableButtonBase(props: DraggableButtonBaseProps) {
<ButtonBase
{...rest}
onPointerDown={(e) => {
if (!isValidPointer(e)) {
return;
}
if (pointerId !== null) {
return; //tracking another pointer already.
}
e.currentTarget.setPointerCapture(e.pointerId);
setPointerId(e.pointerId);
setPointerDownPoint(screenToClient(e.currentTarget,{ x: e.screenX, y: e.screenY }));
+106 -95
View File
@@ -49,7 +49,7 @@ import Toolbar from '@mui/material/Toolbar';
import WithStyles from './WithStyles';
import { withStyles } from "tss-react/mui";
import CircularProgress from '@mui/material/CircularProgress';
import { pathConcat,pathParentDirectory,pathFileName,pathFileNameOnly, pathExtension } from './FileUtils'; './FileUtils';
import ResizeResponsiveComponent from './ResizeResponsiveComponent';
@@ -139,6 +139,7 @@ export interface FilePropertyDialogState {
fileResult: FileRequestResult;
navDirectory: string;
windowWidth: number;
windowHeight: number;
currentDirectory: string;
isProtectedDirectory: boolean;
columns: number;
@@ -158,60 +159,6 @@ class DragState {
from: number = 0;
to: number = 0;
};
function pathExtension(path: string) {
let dotPos = path.lastIndexOf('.');
if (dotPos === -1) return "";
let slashPos = path.lastIndexOf('/');
if (slashPos !== -1) {
if (dotPos <= slashPos + 1) return "";
}
return path.substring(dotPos); // include the '.'.
}
function pathParentDirectory(path: string) {
let npos = path.lastIndexOf('/');
if (npos === -1) return "";
return path.substring(0, npos);
}
function pathConcat(left: string, right: string) {
if (left === "") return right;
if (right === "") return left;
if (left.endsWith('/')) {
left = left.substring(0, left.length - 1);
}
if (right.startsWith("/")) {
right = right.substring(1);
}
return left + "/" + right;
}
export function pathFileNameOnly(path: string): string {
if (path === "..") return path;
let slashPos = path.lastIndexOf('/');
if (slashPos < 0) {
slashPos = 0;
} else {
++slashPos;
}
let extPos = path.lastIndexOf('.');
if (extPos < 0 || extPos < slashPos) {
extPos = path.length;
}
return path.substring(slashPos, extPos);
}
export function pathFileName(path: string): string {
if (path === "..") return path;
let slashPos = path.lastIndexOf('/');
if (slashPos < 0) {
slashPos = 0;
} else {
++slashPos;
}
return path.substring(slashPos);
}
export default withStyles(
class FilePropertyDialog extends ResizeResponsiveComponent<FilePropertyDialogProps, FilePropertyDialogState> {
@@ -242,6 +189,7 @@ export default withStyles(
selectedFile: selectedFile,
selectedFileProtected: true,
windowWidth: this.windowSize.width,
windowHeight: this.windowSize.height,
selectedFileIsDirectory: false,
navDirectory: this.getNavDirectoryFromFile(selectedFile, props.fileProperty),
currentDirectory: "",
@@ -370,7 +318,7 @@ export default withStyles(
element.style.zIndex = "1000";
element.style.top = "5px";
element.style.left = "5px";
element.style.background = isDarkMode() ? "#555": "#EEF" // xxx: dark mode.
element.style.background = isDarkMode() ? "#555" : "#EEF" // xxx: dark mode.
if (this.lastDivRef) {
this.longPressStartPoint = screenToClient(this.lastDivRef, { x: e.screenX, y: e.screenY });
}
@@ -450,15 +398,15 @@ export default withStyles(
newFileResult.files.splice(ixTo, 0, newFileResult.files[ixFrom]);
newFileResult.files.splice(ixFrom + 1, 1);
} else {
// move down.
newFileResult.files.splice(ixTo+1,0,newFileResult.files[ixFrom]);
// move down.
newFileResult.files.splice(ixTo + 1, 0, newFileResult.files[ixFrom]);
newFileResult.files.splice(ixFrom, 1);
}
this.setState({ fileResult: newFileResult ,dragState: null});
this.setState({ fileResult: newFileResult, dragState: null });
// Now send the reorder request to the server.
this.model.reorderAudioFiles(
this.model.moveAudioFile(
this.state.currentDirectory, from, to);
}
handleLongPressEnd(e: React.PointerEvent<HTMLButtonElement>) {
@@ -499,7 +447,8 @@ export default withStyles(
this.setState({
fullScreen: this.getFullScreen(),
windowWidth: width
windowWidth: width,
windowHeight: height
})
if (this.lastDivRef !== null) {
this.onMeasureRef(this.lastDivRef);
@@ -679,9 +628,32 @@ export default withStyles(
this.requestFiles(relativeDirectory);
this.setState({ navDirectory: relativeDirectory });
}
getCompactTrackTitle(fileEntry: FileEntry): string {
let metadata = fileEntry.metadata;
if (!metadata) {
return "#error";
}
let title = this.getTrackTitle(fileEntry);
if (metadata.album !== "") {
title += " (" + metadata.album + ")";
}
return title;
}
getAlbumTitle(fileEntry: FileEntry): string {
let artist = fileEntry.metadata?.artist || "";
if (artist == "" ) {
artist = fileEntry.metadata?.albumArtist || "";
}
let album = fileEntry.metadata?.album || "";
let joiner = (artist !== "" && album !== "") ? " - " : "";
return album + joiner + artist;
}
getTrackTitle(fileEntry: FileEntry): string {
if (!fileEntry.metadata) {
return fileEntry.displayName;
return pathFileName(fileEntry.pathname);
}
if (fileEntry.metadata.title === "") {
return pathFileName(fileEntry.pathname);
}
let metadata = fileEntry.metadata;
let trackDisplay = "";
@@ -692,7 +664,11 @@ export default withStyles(
trackDisplay = metadata.track.toString() + ". ";
}
}
return trackDisplay + metadata.title;
let result = trackDisplay + metadata.title;
if (result === "") {
result = pathFileName(fileEntry.pathname);
}
return result;
}
getTrackThumbnail(fileEntry: FileEntry): string {
return getAlbumArtUri(this.model, fileEntry.metadata, fileEntry.pathname);
@@ -793,19 +769,29 @@ export default withStyles(
}
return result;
}
private getIcon(fileEntry: FileEntry) {
private getIcon(fileEntry: FileEntry, largeIcon: boolean) {
let style = largeIcon
? {
flex: "0 0 auto", opacity: 0.7, width: 32, height: 32,
marginLeft: 16, marginRight: 24, marginTop: 16, marginBottom: 16,
}
: { flex: "0 0 auto", opacity: 0.7, marginRight: 8, marginLeft: 8 };
if (fileEntry.pathname === "") {
return (<InsertDriveFileOutlinedIcon style={{ flex: "0 0 auto", opacity: 0.5, marginRight: 8, marginLeft: 8 }} />);
return (<InsertDriveFileOutlinedIcon style={style} />);
}
if (fileEntry.isDirectory) {
return (
<FolderIcon style={{ flex: "0 0 auto", opacity: 0.7, marginRight: 8, marginLeft: 8 }} />
<FolderIcon style={style} />
);
}
if (isAudioFile(fileEntry.pathname)) {
return (<AudioFileIcon style={{ flex: "0 0 auto", opacity: 0.7, marginRight: 8, marginLeft: 8 }} />);
return (<AudioFileIcon style={style} />);
}
return (<InsertDriveFileIcon style={{ flex: "0 0 auto", opacity: 0.7, marginRight: 8, marginLeft: 8 }} />);
return (<InsertDriveFileIcon style={style} />);
}
render() {
@@ -827,6 +813,7 @@ export default withStyles(
let canReorder = isTracksDirectory;
let needsDivider = canMove || canRename || canReorder;
let trackPosition = 0;
let compactVertical = this.state.windowHeight < 700;
return this.props.open &&
(
@@ -1031,7 +1018,7 @@ export default withStyles(
}
style={{
width: columnWidth, flex: "0 0 auto", height: value.metadata ? 64 : 48,
width: columnWidth, flex: "0 0 auto", height: (value.metadata && !compactVertical) ? 64 : 48,
position: "relative",
top: dragOffset + "px",
}}
@@ -1054,39 +1041,63 @@ export default withStyles(
>
<div ref={scrollRef} style={{ position: "absolute", background: selectBg, width: "100%", height: "100%", borderRadius: 4 }} />
{value.metadata ?
(
<div style={{
display: "flex", flexFlow: "row nowrap", textOverflow: "ellipsis",
justifyContent: "start", alignItems: "center", width: "100%", height: "100%"
}}>
<img
onDragStart={(e) => { e.preventDefault(); }}
src={this.getTrackThumbnail(value)}
style={{ width: 48, height: 48, margin: 8, borderRadius: 4 }} />
(!compactVertical ?
(
<div style={{
flex: "1 1 1px", display: "flex", flexFlow: "column nowrap",
marginLeft: 8,
textOverflow: "ellipsis", justifyContent: "center", alignItems: "stretch"
display: "flex", flexFlow: "row nowrap", textOverflow: "ellipsis",
justifyContent: "start", alignItems: "center", width: "100%", height: "100%"
}}>
<Typography noWrap
className={classes.secondaryText}
variant="body2"
style={{ flex: "0 0 auto", textOverflow: "ellipsis", textAlign: "left",marginBottom: 4 }}>
{this.getTrackTitle(value)}
</Typography>
<Typography noWrap className={classes.secondaryText}
variant="body2"
color="textSecondary"
style={{ flex: "0 0 auto", textOverflow: "ellipsis", textAlign: "left", fontSize: "0.95em" }}>
{value.metadata?.album ?? "#error"}
<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,
textOverflow: "ellipsis", justifyContent: "center", alignItems: "stretch"
}}>
<Typography noWrap
className={classes.secondaryText}
variant="body2"
style={{ flex: "0 0 auto", textOverflow: "ellipsis", textAlign: "left", marginBottom: 4 }}>
{this.getTrackTitle(value)}
</Typography>
<Typography noWrap className={classes.secondaryText}
variant="body2"
color="textSecondary"
style={{ flex: "0 0 auto", textOverflow: "ellipsis", textAlign: "left", fontSize: "0.95em" }}>
{this.getAlbumTitle(value)}
</Typography>
</Typography>
</div>
</div>
</div>
) : (
<div style={{
display: "flex", flexFlow: "row nowrap", textOverflow: "ellipsis",
justifyContent: "start", alignItems: "center", width: "100%", height: "100%"
}}>
<img
onDragStart={(e) => { e.preventDefault(); }}
src={this.getTrackThumbnail(value)}
style={{ width: 24, height: 24, margin: 8, borderRadius: 4 }} />
<div style={{
flex: "1 1 1px", display: "flex", flexFlow: "column nowrap",
marginLeft: 8,
textOverflow: "ellipsis", justifyContent: "center", alignItems: "stretch"
}}>
<Typography noWrap
className={classes.secondaryText}
variant="body2"
style={{ flex: "0 0 auto", textOverflow: "ellipsis", textAlign: "left", marginBottom: 4 }}>
{this.getCompactTrackTitle(value)}
</Typography>
</div>
</div>
)
) : (
<div style={{ display: "flex", flexFlow: "row nowrap", textOverflow: "ellipsis", justifyContent: "start", alignItems: "center", width: "100%", height: "100%" }}>
{this.getIcon(value)}
{this.getIcon(value, this.isTracksDirectory() && !compactVertical)}
<Typography noWrap className={classes.secondaryText} variant="body2" style={{ flex: "1 1 auto", textAlign: "left" }}>{displayValue}</Typography>
</div>
)}
+80
View File
@@ -0,0 +1,80 @@
/*
* Copyright (c) 2025 Robin E. R. Davies
* All rights reserved.
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
export function pathExtension(path: string) {
let dotPos = path.lastIndexOf('.');
if (dotPos === -1) return "";
let slashPos = path.lastIndexOf('/');
if (slashPos !== -1) {
if (dotPos <= slashPos + 1) return "";
}
return path.substring(dotPos); // include the '.'.
}
export function pathParentDirectory(path: string) {
let npos = path.lastIndexOf('/');
if (npos === -1) return "";
return path.substring(0, npos);
}
export function pathConcat(left: string, right: string) {
if (left === "") return right;
if (right === "") return left;
if (left.endsWith('/')) {
left = left.substring(0, left.length - 1);
}
if (right.startsWith("/")) {
right = right.substring(1);
}
return left + "/" + right;
}
export function pathFileNameOnly(path: string): string {
if (path === "..") return path;
let slashPos = path.lastIndexOf('/');
if (slashPos < 0) {
slashPos = 0;
} else {
++slashPos;
}
let extPos = path.lastIndexOf('.');
if (extPos < 0 || extPos < slashPos) {
extPos = path.length;
}
return path.substring(slashPos, extPos);
}
export function pathFileName(path: string): string {
if (path === "..") return path;
let slashPos = path.lastIndexOf('/');
if (slashPos < 0) {
slashPos = 0;
} else {
++slashPos;
}
return path.substring(slashPos);
}
+630
View File
@@ -0,0 +1,630 @@
/*
* Copyright (c) 2025 Robin E. R. Davies
* All rights reserved.
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { SyntheticEvent, useEffect } from "react";
import FormGroup from "@mui/material/FormGroup";
import FormControlLabel from "@mui/material/FormControlLabel";
import DialogContent from "@mui/material/DialogContent";
import TextField, { StandardTextFieldProps } from "@mui/material/TextField";
import DialogTitle from "@mui/material/DialogTitle";
import DialogEx from "./DialogEx";
import Toolbar from "@mui/material/Toolbar";
import IconButton from "@mui/material/IconButton";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import Typography from "@mui/material/Typography";
import Slider, { SliderProps } from "@mui/material/Slider";
import Checkbox from "@mui/material/Checkbox";
import TimebaseSelectorDialog from "./TimebaseselectorDialog";
import Timebase, { TimebaseUnits } from "./Timebase";
export interface LoopDialogProps {
isOpen: boolean;
onClose: () => void;
onSetLoop: (start: number, loopEnable: boolean, loopStart: number, loopEnd: number) => void;
start: number;
loopEnable: boolean;
loopStart: number;
loopEnd: number;
duration: number;
fullScreen?: boolean;
timebase: Timebase;
onTimebaseChange: (timebase: Timebase) => void; // Optional callback for timebase changes
sampleRate: number;
};
interface TimeEditProps extends StandardTextFieldProps {
timebase: Timebase;
sampleRate: number;
value: number;
onValueChange?: (e: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>, value: number) => void;
onBlur: () => void;
max: number;
};
function parseTime(timebase: Timebase, sampleRate: number, duration: number, timeString: string): number {
switch (timebase.units) {
case TimebaseUnits.Samples:
{
const samples = parseInt(timeString, 10);
if (isNaN(samples)) {
return samples;
}
let result = samples / sampleRate; // Convert samples to seconds
if (result < 0) {
result = 0;
}
if (result > duration) {
result = duration;
}
return result;
}
case TimebaseUnits.Seconds:
{
const parts = timeString.split(':');
let hours = 0;
let minutes = 0;
let seconds = 0;
if (parts.length === 1) {
hours = 0;
minutes = 0;
seconds = parseFloat(parts[0]);
if (isNaN(seconds)) {
throw new Error("Invalid seconds format. Use 'mm:ss' or 'mm:ss.mmmm'.");
}
} else if (parts.length === 2) {
hours = 0;
minutes = parseInt(parts[0], 10);
seconds = parseFloat(parts[1]);
if (isNaN(minutes) || isNaN(seconds)) {
throw new Error("Invalid minutes or seconds format. Use 'mm:ss' or 'mm:ss.mmmm'.");
}
} else if (parts.length === 3) {
hours = parseInt(parts[0]);
minutes = parseInt(parts[1], 10);
seconds = parseFloat(parts[2]);
if (isNaN(hours) || isNaN(minutes) || isNaN(seconds)) {
throw new Error("Invalid hours, minutes or seconds format. Use 'hh:mm:ss' or 'mm:ss.mmmm'.");
}
} else {
throw new Error("Invalid time format. Use 'hh:mm:ss' or 'mm:ss.mmmm'.");
}
let result = hours * 60 * 60 + minutes * 60 + seconds; // Convert total time to seconds
if (result < 0) {
result = 0;
}
if (result > duration) {
result = duration;
}
return result;
}
case TimebaseUnits.Beats:
{
const parts = timeString.split(':');
let bar = 0;
let beat = 0;
if (parts.length === 1) {
bar = 1;
beat = parseFloat(parts[0]);
if (isNaN(beat)) {
throw new Error("Invalid seconds format. Use 'bar:beat' or 'bar:beat.fraction'.");
}
if (beat < 1 || beat >= timebase.timeSignature.numerator+1) {
throw new Error(`Beat number must be between 1 and ${timebase.timeSignature.numerator+1}.`);
}
} else if (parts.length === 2) {
bar = parseInt(parts[0], 10);
if (bar < 1) {
throw new Error("Bar number must be 1 or greater.");
}
beat = parseFloat(parts[1]);
if (isNaN(bar) || isNaN(beat)) {
throw new Error("Invalid bar or beat format. Use 'bar:beat' or 'bar:beat.fraction'.");
}
if (beat < 1 || beat >= timebase.timeSignature.numerator+1) {
throw new Error(`Beat number must be between 1 and ${timebase.timeSignature.numerator+1}.`);
}
} else {
throw new Error("Invalid time format. Use 'hh:mm:ss' or 'mm:ss.mmmm'.");
}
const actualBeat = (bar-1) * timebase.timeSignature.numerator + (beat-1); // Convert bar and beat to actual beat number
let result = actualBeat *60/ timebase.tempo; // Convert beats to seconds based on tempo
if (result < 0) {
result = 0;
}
if (result > duration) {
result = duration;
}
return result;
}
}
}
function formatTime(timebase: Timebase, sampleRate: number, seconds: number): string {
switch (timebase.units) {
case TimebaseUnits.Samples:
{
return Math.round(seconds * sampleRate).toString();
}
break;
case TimebaseUnits.Seconds:
{
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
const mmillis = Math.floor((seconds % 1) * 10000);
if (hours == 0) {
return `${minutes}:${secs.toString().padStart(2, '0')}.${mmillis.toString().padStart(4, '0')}`;
} else {
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}.${mmillis.toString().padStart(4, '0')}`;
}
break;
}
case TimebaseUnits.Beats:
{
let beats = timebase.tempo / 60.0 * seconds;
const bars = Math.floor(beats / timebase.timeSignature.numerator);
const beat = (beats - bars * timebase.timeSignature.numerator);
return `${bars + 1}:${(beat + 1).toFixed(4) }`;
}
break;
default:
throw new Error("Unsupported timebase units");
}
}
interface SliderWithPreviewProps extends SliderProps {
style?: React.CSSProperties;
value: number | number[];
onChange: (event: Event | SyntheticEvent, value: number | number[], thumb?: number) => void;
onPreview?: (event: Event | SyntheticEvent, value: number | number[], thumb?: number) => void;
onCommitPreviewValue?: (event: Event | SyntheticEvent, value: number | number[], thumb?: number) => void;
}
interface PreviewArguments {
newValue: number | number[];
thumb?: number;
};
function SliderWithPreview(props: SliderWithPreviewProps) {
const { value, onChange, onPreview, onCommitPreviewValue, style, ...extra } = props;
const [previewArguments, setPreviewArguments] =
React.useState<PreviewArguments | null>(null);
const [pointerDown, setPointerDown] = React.useState(false);
// code to handle a bug in crhome relating to mouseup events, that causes zero values to not be set.
const handleMouseUp = (e: MouseEvent) => {
if (pointerDown && e.button === 0) { // Left mouse button
setPointerDown(false);
if (previewArguments) {
if (onCommitPreviewValue) {
onCommitPreviewValue(e as Event, previewArguments.newValue, previewArguments.thumb);
}
setPreviewArguments(null);
}
}
};
useEffect(() => {
document.addEventListener('mouseup', handleMouseUp);
// Cleanup: Remove the event listener when the component unmounts
return () => {
document.removeEventListener('mouseup', handleMouseUp);
};
}, []); // Re-run effect when tempValue changes to ensure latest value is committed
return (
<Slider
{...extra}
style={style}
step={0.0001}
value={previewArguments ? previewArguments.newValue : props.value}
onChange={(event, newValue, thumb) => {
if (pointerDown) {
if (props.onPreview) {
props.onPreview(event, newValue, thumb);
}
setPreviewArguments({ newValue, thumb });
} else {
if (props.onChange) {
props.onChange(event, newValue, thumb);
}
return;
}
}}
onChangeCommitted={(event, newValue) => {
if (pointerDown) {
if (props.onPreview) {
props.onPreview(event as SyntheticEvent, newValue, 0);
}
setPreviewArguments({ newValue: newValue, thumb: 0 });
} else {
if (props.onChange) {
props.onChange(event, newValue, 0);
}
return;
}
}}
onMouseDown={(e) => {
if (e.button == 0) {
setPointerDown(true);
}
}}
onMouseUp={(e) => {
if (e.button == 0) { // Left mouse button
setPointerDown(false);
if (previewArguments) {
if (onCommitPreviewValue) {
onCommitPreviewValue(e, previewArguments.newValue, previewArguments.thumb);
}
setPreviewArguments(null);
}
}
}}
onMouseMove={(e) => {
if (pointerDown && previewArguments) {
if (props.onPreview) {
if (e.clientX < 0) // bug in crhrome
{
props.onPreview(e, previewArguments.newValue, previewArguments.thumb);
}
}
}
}
}
/>
);
}
function TimeEdit(props: TimeEditProps) {
let { value, onValueChange, onBlur, timebase,sampleRate,max, ...extra } = props;
const [text, setText] = React.useState(formatTime(timebase, props.sampleRate, props.value));
const [error, setError] = React.useState(false);
const [editValue, setEditValue] = React.useState(props.value);
const [focus, setFocus] = React.useState(false);
// slice props.
React.useEffect(() => {
if (!focus ) {
setText(formatTime(timebase, sampleRate, props.value));
}
},
[props.value]);
React.useEffect(() => {
setText(formatTime(props.timebase, sampleRate, props.value));
}, [props.timebase, sampleRate]);
return (
<TextField
{...extra}
variant="standard"
spellCheck="false"
error={error}
value={text}
onBlur={() => {
console.log("onBlur", text, props.value, editValue,
formatTime(props.timebase, sampleRate, props.value));
console.log("onBlur", props.value);
setText(formatTime(props.timebase, sampleRate, props.value));
setFocus(false);
}}
onChange={(e) => {
try {
setText(e.target.value);
let val = parseTime(props.timebase, sampleRate, max, e.target.value);
if (!isNaN(val)) {
if (val > max) {
val = max;
}
setEditValue(val);
if (props.onValueChange) {
props.onValueChange(e, val);
}
setError(false);
} else {
setError(true);
}
} catch (err) {
setError(true);
}
}}
onFocus={(e) => {
setFocus(true);
setText(formatTime(props.timebase, sampleRate, props.value));
setError(false);
e.target.select();
}
}
inputProps={{
style: { textAlign: "center" },
autoComplete: "off",
spellCheck: "false",
autoCorrect: "off",
autoCapitalize: "off",
}}
/>
);
}
export default function LoopDialog(props: LoopDialogProps) {
const [previewStartValue, setPreviewStartValue] = React.useState<number | null>(null);
const [previewLoopStart, setPreviewLoopStart] = React.useState<number | null>(null);
const [previewLoopEnd, setPreviewLoopEnd] = React.useState<number | null>(null);
return (
<DialogEx
tag="LoopDialog"
onClose={props.onClose}
fullScreen={false}
maxWidth="xl"
open={props.isOpen}
onEnterKey={() => {
}}
sx={{
"& .MuiDialog-container": {
"& .MuiPaper-root": {
width: "100%",
maxWidth: "500px", // Set your width here
},
},
}}
>
<DialogTitle style={{ paddingTop: 0 }}>
<Toolbar style={{ padding: 0 }}>
<IconButton
edge="start"
color="inherit"
aria-label="back"
style={{ opacity: 0.6 }}
onClick={() => { props.onClose(); }}
>
<ArrowBackIcon style={{ width: 24, height: 24 }} />
</IconButton>
<Typography noWrap component="div" sx={{ flexGrow: 1 }}>
Loop
</Typography>
<div style={{ flexGrow: 1 }} />
<TimebaseSelectorDialog
onTimebaseChange={(timebase) => {
props.onTimebaseChange(timebase); // Handle timebase change if needed
}}
timebase={props.timebase} // Pass the current timebase if needed
/>
</Toolbar>
</DialogTitle>
<DialogContent style={{}}>
<div style={{
position: "relative",
display: "flex", justifyContent: "stretch", flexFlow: "column nowrap", marginLeft: 32, marginRight: 32,
marginBottom: 16
}}>
<Typography display="block" variant="body1" gutterBottom style={{}}>
Start
</Typography>
<SliderWithPreview
style={{
width: "100%"
}}
value={props.start}
min={0}
max={props.duration}
onChange={(e, v, thumb) => {
let val = Array.isArray(v) ? v[0] : v
if (props.onSetLoop) {
props.onSetLoop(val, props.loopEnable, props.loopStart, props.loopEnd);
}
}}
onPreview={(e, v, thumb) => {
let val = Array.isArray(v) ? v[0] : v
setPreviewStartValue(val);
}}
onCommitPreviewValue={(e, v, thumb) => {
let val = Array.isArray(v) ? v[0] : v
setPreviewStartValue(null);
if (props.onSetLoop) {
props.onSetLoop(val, props.loopEnable, props.loopStart, props.loopEnd);
}
}}
valueLabelFormat={(v) => formatTime(props.timebase, props.sampleRate, v)}
/>
<TimeEdit variant="standard" spellCheck="false"
value={previewStartValue ? previewStartValue : props.start}
max={props.duration}
timebase={props.timebase}
sampleRate={props.sampleRate}
onBlur={() => {
let t = props.start;
if (t > props.duration) {
t = props.duration;
if (props.onSetLoop) {
props.onSetLoop(t, props.loopEnable, props.loopStart, props.loopEnd);
}
}
}}
onValueChange={(e, val) => {
if (props.onSetLoop) {
props.onSetLoop(val, props.loopEnable, props.loopStart, props.loopEnd);
}
}
}
style={{ width: 120, alignSelf: "center", textAlign: "center" }} />
<FormGroup style={{ alignSelf: "start", marginTop: 8, marginBottom: 8, marginLeft: 0 }}>
<FormControlLabel
labelPlacement="start"
style={{ marginLeft: 0 }}
control={<Checkbox checked={props.loopEnable}
onChange={(e, checked) => {
props.onSetLoop(props.start, !props.loopEnable, props.loopStart, props.loopEnd);
}}
/>} label="Enable loop" />
</FormGroup>
<SliderWithPreview
style={{
width: "100%",
opacity: props.loopEnable ? 1 : 0.4,
}}
disabled={!props.loopEnable}
value={
previewLoopStart != null ?
[previewLoopStart as number, previewLoopEnd as number]
: [props.loopStart, props.loopEnd]}
min={0}
max={props.duration}
onChange={(e, v, thumb) => {
if (!Array.isArray(v)) throw new Error("Expected array for loop start and end preview");
if (props.onSetLoop) {
props.onSetLoop(props.start, props.loopEnable, v[0], v[1]);
}
}}
onPreview={(e, v, thumb) => {
if (!Array.isArray(v)) throw new Error("Expected array for loop start and end preview");
setPreviewLoopStart(v[0]);
setPreviewLoopEnd(v[1]);
}}
onCommitPreviewValue={(e, v, thumb) => {
if (!Array.isArray(v)) throw new Error("Expected array for loop start and end preview");
setPreviewLoopStart(null);
setPreviewLoopEnd(null);
props.onSetLoop(props.start, props.loopEnable, v[0], v[1]);
}}
/>
<div style={{ display: "flex", justifyContent: "space-evenly", columnGap: 8 }}>
<TimeEdit variant="standard" spellCheck="false"
value={previewLoopStart ? previewLoopStart : props.loopStart}
disabled={!props.loopEnable}
max={props.duration}
timebase={props.timebase}
sampleRate={props.sampleRate}
style={{
maxWidth: 120, textAlign: "center", opacity: props.loopEnable ? 1 : 0.4,
flex: "1 1 auto"
}}
onBlur={() => {
let tStart = props.loopStart;
if (tStart > props.duration) {
tStart = props.duration;
}
let tEnd = props.loopEnd;
if (tStart > tEnd) {
let t = tStart;
tStart = tEnd;
tEnd = t;
if (props.onSetLoop) {
props.onSetLoop(props.start, props.loopEnable, tStart, tEnd);
}
}
}}
onValueChange={(e, val) => {
if (props.onSetLoop) {
props.onSetLoop(props.start, props.loopEnable, val, props.loopEnd);
}
}}
/>
<TimeEdit variant="standard" spellCheck="false"
max={props.duration}
value={previewLoopEnd ? previewLoopEnd : props.loopEnd}
sampleRate={props.sampleRate}
disabled={!props.loopEnable}
timebase={props.timebase}
style={{
maxWidth: 120, textAlign: "center", opacity: props.loopEnable ? 1 : 0.4,
flex: "1 1 auto"
}}
onBlur={() => {
let tStart = props.loopStart;
let tEnd = props.loopEnd;;
if (tEnd < tStart) {
let t = tEnd;
tEnd = tStart;
tStart = t;
if (props.onSetLoop) {
props.onSetLoop(props.start, props.loopEnable, tStart, tEnd);
}
}
}}
onValueChange={(e, val) => {
if (props.onSetLoop) {
props.onSetLoop(props.start, props.loopEnable, props.loopStart, val);
}
}}
/>
</div>
</div>
</DialogContent>
</DialogEx>
)
}
+23 -11
View File
@@ -919,6 +919,7 @@ export class PiPedalModel //implements PiPedalModel
}
async onSocketReconnected(): Promise<void> {
this.cancelOnNetworkChanging();
this.cancelAndroidReconnectTimer();
@@ -977,6 +978,7 @@ export class PiPedalModel //implements PiPedalModel
}
}
async getAudioFileMetadata(filePath: string): Promise<AudioFileMetadata> {
try {
let url =
@@ -1822,6 +1824,27 @@ export class PiPedalModel //implements PiPedalModel
.request("setOnboarding", value);
}
moveAudioFile(
path: string,
from: number,
to: number
): Promise<boolean> {
return new Promise<boolean>(
(accept, reject) => {
this.webSocket?.request<boolean>("moveAudioFile", {
path: path,
from: from,
to: to
}).then(() => {
accept(true);
}).catch((e) => {
this.setError(getErrorMessage(e));
accept(false);
});
}
);
}
saveCurrentPresetAs(newName: string, saveAfterInstanceId = -1): Promise<number> {
// default behaviour is to save after the currently selected preset.
@@ -3094,17 +3117,6 @@ export class PiPedalModel //implements PiPedalModel
}
reorderAudioFiles(
path: string,
from: number,
to: number
) {
this.webSocket?.send("reorderAudioFiles", {
path: path,
from: from,
to: to
})
}
};
let instance: PiPedalModel | undefined = undefined;
+52
View File
@@ -0,0 +1,52 @@
/*
* Copyright (c) 2025 Robin E. R. Davies
* All rights reserved.
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
export enum TimebaseUnits {
Seconds = 0,
Samples = 1,
Beats = 2,
}
export interface TimeSignature {
numerator: number;
denominator: number;
}
export default interface Timebase {
units: TimebaseUnits;
tempo: number;
timeSignature: TimeSignature;
}
export interface LoopParameters
{
start: number;
loopEnable: boolean;
loopStart: number;
loopEnd: number;
};
export interface ToobPlayerSettings {
timebase: Timebase;
loopParameters: LoopParameters;
};
+307
View File
@@ -0,0 +1,307 @@
/*
* Copyright (c) 2025 Robin E. R. Davies
* All rights reserved.
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { useState } from 'react';
import DialogEx from './DialogEx';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import Button from '@mui/material/Button';
import Select from '@mui/material/Select';
import MenuItem from '@mui/material/MenuItem';
import TextField, { StandardTextFieldProps } from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import Box from '@mui/material/Box';
import Toolbar from '@mui/material/Toolbar';
import IconButton from '@mui/material/IconButton';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import Timebase, { TimebaseUnits } from './Timebase';
interface TimebaseSelectorDialogProps {
timebase: Timebase;
onTimebaseChange: (timebase: Timebase) => void;
}
interface NumericEditProps extends StandardTextFieldProps {
value: number;
onValueChange: (value: number) => void;
parse: (value: string) => number;
min: number;
max: number;
step?: number;
style?: React.CSSProperties;
}
function NumericEdit(props: NumericEditProps) {
let { value, min, max, parse, step, onValueChange,style, ...extras } = props;
let [text, setText] = React.useState(value.toString());
let [error, setError] = React.useState(false);
let [focus, setFocus] = React.useState(false);
React.useEffect(() => {
if (!focus) {
setText(value.toString());
setError(false);
}
}, [value]);
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
let newValue = parse(event.target.value);
if (!isNaN(newValue) && newValue >= min && newValue <= max) {
setText(event.target.value);
onValueChange(newValue);
setError(false);
} else {
setText(event.target.value);
setError(true);
}
};
return (
<TextField
{...extras}
style={style}
variant="standard"
type="number"
value={text}
onChange={handleChange}
inputProps={{
min: props.min,
max: props.max,
step: props.step,
style: { textAlign: 'center' }
}}
error={error}
onFocus={(e) => {
setFocus(true);
e.target.select();
}}
onBlur={() => {
setFocus(false);
let newValue = parse(text);
if (isNaN(newValue)) {
setText(value.toString());
} else if (newValue < min) {
newValue = min;
setText(value.toString());
onValueChange(newValue);
} else if (newValue > max) {
newValue = max;
setText(newValue.toString());
onValueChange(value)
} else {
setText(newValue.toString());
onValueChange(newValue);
}
setError(false);
}}
/>
);
}
export default function TimebaseSelectorDialog(props: TimebaseSelectorDialogProps) {
const { timebase, onTimebaseChange } = props;
const [open, setOpen] = useState(false);
const [editingTimebase, setEditingTimebase] = useState<Timebase>({ ...timebase });
const handleOpen = () => {
setEditingTimebase({ ...timebase });
setOpen(true);
};
const handleClose = () => {
setOpen(false);
};
const enableBeatControls = editingTimebase.units === TimebaseUnits.Beats;
const handleChange = (timebase: Timebase) => {
onTimebaseChange(timebase);
};
const handleTimebaseTypeChange = (event: any) => {
let value = {
...editingTimebase,
units: event.target.value as TimebaseUnits
};
setEditingTimebase(value);
handleChange(value);
};
const handleTempoChange = (tempo: number) => {
let value = {
...editingTimebase,
tempo: tempo
};
setEditingTimebase(value);
handleChange(value);
};
const handleTimeSignatureChange = (field: 'numerator' | 'denominator', numVal: number) => {
let value = {
...editingTimebase,
timeSignature: {
...editingTimebase.timeSignature,
[field]: numVal
}
};
setEditingTimebase(value);
handleChange(value);
};
const getTimebaseTypeLabel = (timebase: Timebase): string => {
switch (timebase.units) {
case TimebaseUnits.Seconds: return 'Seconds';
case TimebaseUnits.Samples: return 'Samples';
case TimebaseUnits.Beats:
{
return timebase.tempo.toString() +
" bpm (" +
timebase.timeSignature.numerator.toString() +
"/" + timebase.timeSignature.denominator.toString() + ")";
}
default: return 'Unknown';
}
};
return (
<>
<Button variant="dialogSecondary" onClick={handleOpen}
style={{ textTransform: 'none' }}
>
Units: {getTimebaseTypeLabel(timebase)}
</Button>
<DialogEx tag="timebasesettings"
open={open} onClose={handleClose} maxWidth="sm"
onEnterKey={() => { }}
>
<DialogTitle style={{ paddingTop: 0 }}>
<Toolbar style={{ padding: 0 }}>
<IconButton
edge="start"
color="inherit"
aria-label="back"
style={{ opacity: 0.6 }}
onClick={() => { handleClose(); }}
>
<ArrowBackIcon style={{ width: 24, height: 24 }} />
</IconButton>
<Typography noWrap component="div" sx={{ flexGrow: 1 }}>
Timebase Settings
</Typography>
<div style={{ flexGrow: 1 }} />
</Toolbar>
</DialogTitle>
<DialogContent>
<Box sx={{
paddingLeft: 0, paddingRight: 0,
paddingTop: 0, paddingBottom: 1
}}>
<div style={{ display: "flex", flexFlow: "column nowrap", alignItems: "start" }}>
<Typography variant="caption">
Units
</Typography>
<Select variant="standard"
style={{ width: 180 }}
value={editingTimebase.units}
label="Timebase"
onChange={handleTimebaseTypeChange}
>
<MenuItem value={TimebaseUnits.Seconds}>Seconds</MenuItem>
<MenuItem value={TimebaseUnits.Samples}>Samples</MenuItem>
<MenuItem value={TimebaseUnits.Beats}>Beats</MenuItem>
</Select>
<div style={{ opacity: enableBeatControls ? 1 : 0.4, }}
>
<Typography display="block" variant="caption" style={{ paddingTop: 24 }}>
Tempo (BPM)
</Typography>
<NumericEdit
parse={(value: string) => parseFloat(value)}
min={10}
max={400}
step={1}
onValueChange={handleTempoChange}
value={editingTimebase.tempo}
disabled={!enableBeatControls}
variant="standard"
type="number"
style={{ width: 120, }}
/>
<Typography display="block" variant="caption" style={{ paddingTop: 24 }}>
Time Signature
</Typography>
<div style={{
display: "flex", flexFlow: "row nowrap",
alignItems: "center", justifyContent: "start"
}}>
<NumericEdit
parse={(value: string) => parseInt(value)}
min={1}
max={32}
step={1}
onValueChange={(value) => handleTimeSignatureChange('numerator', value)}
value={editingTimebase.timeSignature.numerator}
style={{ width: 80 }}
variant="standard"
type="number"
disabled={!enableBeatControls}
/>
<Typography variant="body2">&nbsp;/&nbsp;</Typography>
<Select variant="standard"
style={{ width: 80 }}
value={editingTimebase.timeSignature.denominator}
disabled={!enableBeatControls}
label="Timebase"
sx={{
'& .MuiSelect-select': {
textAlign: 'center'
}
}}
onChange={(e) => {
handleTimeSignatureChange('denominator', parseInt(e.target.value.toString()));
}
}
>
<MenuItem value={2} sx={{ justifyContent: 'center' }}>2</MenuItem>
<MenuItem value={4} sx={{ justifyContent: 'center' }}>4</MenuItem>
<MenuItem value={8} sx={{ justifyContent: 'center' }}>8</MenuItem>
</Select>
</div>
</div>
</div>
</Box>
</DialogContent >
</DialogEx >
</>
);
}
+192 -76
View File
@@ -23,17 +23,19 @@
import React, { useEffect } from 'react';
import { styled } from '@mui/material/styles';
import LoopDialog from './LoopDialog';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import Slider from '@mui/material/Slider';
import IconButton from '@mui/material/IconButton';
import Pause from '@mui/icons-material/Pause';
import PlayArrow from '@mui/icons-material/PlayArrow';
import FastForward from '@mui/icons-material/FastForward';
import FastRewind from '@mui/icons-material/FastRewind';
import { PiPedalModelFactory } from './PiPedalModel';
import { PiPedalModelFactory, State } from './PiPedalModel';
import ButtonTooltip from './ButtonTooltip';
import { pathFileNameOnly } from './FilePropertyDialog'
import { pathFileNameOnly } from './FileUtils';
import ButtonBase from '@mui/material/ButtonBase';
import FilePropertyDialog from './FilePropertyDialog';
import JsonAtom from './JsonAtom';
@@ -41,6 +43,8 @@ import { UiFileProperty } from './Lv2Plugin';
import { Divider } from '@mui/material';
import useWindowSize from './UseWindowSize';
import { getAlbumArtUri } from './AudioFileMetadata';
import RepeatIcon from '@mui/icons-material/Repeat';
import { LoopParameters, TimebaseUnits } from './Timebase';
let Player__seek = "http://two-play.com/plugins/toob-player#seek"
const AUDIO_FILE_PROPERTY_URI = "http://two-play.com/plugins/toob-player#audioFile";
@@ -55,6 +59,7 @@ class PluginState {
static Error = 6;
};
const useWallpaper = false;
const WallPaper = styled('div')({
position: 'absolute',
width: '100%',
@@ -87,6 +92,14 @@ const WallPaper = styled('div')({
},
});
function getAlbumLine(album: string, artist: string, albumArtist: string): string {
if (artist === "") {
artist = albumArtist;
}
let joiner = (artist !== "" && album !== "") ? " - " : "";
return album + joiner + artist;
}
interface WidgetProps {
noBorders: boolean;
@@ -104,9 +117,9 @@ const WidgetBorders = styled('div')(({ theme }) => ({
position: 'relative',
zIndex: 1,
backgroundColor: 'rgba(255,255,255,0.4)',
boxShadow: "1px 3px 20px #0008",
boxShadow: "1px 4px 12px rgba(0,0,0,0.2)",
...theme.applyStyles('dark', {
backgroundColor: 'rgba(0,0,0,0.6)',
backgroundColor: useWallpaper? 'rgba(0,0,0,0.6)' : '#282828',
}),
}));
@@ -121,12 +134,26 @@ const WidgetNoBorders = styled('div')(({ theme }) => ({
zIndex: 1,
backgroundColor: 'rgba(255,255,255,0.4)',
...theme.applyStyles('dark', {
backgroundColor: 'rgba(0,0,0,0.6)',
backgroundColor: useWallpaper ? 'rgba(0,0,0,0.6)' : theme.mainBackground,
}),
}));
// const WidgetNoWallpaper = styled('div')(({ theme }) => ({
// padding: 16,
// borderRadius: 0,
// width: "100%",
// height: "100%",
// margin: 0,
// position: 'relative',
// zIndex: 1,
// }));
function Widget(props: WidgetProps) {
// if (!useWallpaper) {
// return(<WidgetNoWallpaper>{props.children} </WidgetNoWallpaper>);
// }
if (props.noBorders) {
return (<WidgetNoBorders>{props.children} </WidgetNoBorders>);
} else {
@@ -163,9 +190,17 @@ export interface ToobPlayerControlProps {
export default function ToobPlayerControl(
props: ToobPlayerControlProps
) {
const model = PiPedalModelFactory.getInstance();
const defaultCoverArt = "/img/default_album.jpg";
const [serverConnected, setServerConnected] = React.useState(model.state.get() == State.Ready);
const [duration, setDuration] = React.useState(0.0);
const [position, setPosition] = React.useState(0.0);
const [start, setStart] = React.useState(0.0);
const [loopStart, setLoopStart] = React.useState(0.0);
const [loopEnable, setLoopEnable] = React.useState(false);
const [loopEnd, setLoopEnd] = React.useState(0.0);
const [dragging, setDragging] = React.useState(false);
const [pluginState, setPluginState] = React.useState(0.0);
const [sliderValue, setSliderValue] = React.useState(0.0);
@@ -173,9 +208,17 @@ export default function ToobPlayerControl(
const [audioFile, setAudioFile] = React.useState("");
const [title, setTitle] = React.useState("");
const [album, setAlbum] = React.useState("");
const [artist, setArtist] = React.useState("");
const [albumArtist, setAlbumArtist] = React.useState("");
const [showFileDialog, setShowFileDialog] = React.useState(false);
const [showLoopDialog, setShowLoopDialog] = React.useState(false);
const [timebase,setTimebase] = React.useState(
{
units: TimebaseUnits.Seconds,
tempo: 120.0,
timeSignature: { numerator: 4, denominator: 4 }
});
const model = PiPedalModelFactory.getInstance();
const [size] = useWindowSize();
const width = size.width;
@@ -185,8 +228,11 @@ export default function ToobPlayerControl(
const useHorizontalLayout = height < HORIZONTAL_CONTROL_SCROLL_HEIGHT_BREAK && width > 573 && width > height;
//const useVerticalScroll = width < 573;
const useQuadMixPanel = width < 720;
const noBorders = width < 420 || height < 720;
let useQuadMixPanel = width < 720;
if (noBorders) {
useQuadMixPanel = width < 370;
}
function SelectFile() {
setShowFileDialog(true);
@@ -202,6 +248,8 @@ export default function ToobPlayerControl(
if (path === "") {
setTitle("");
setAlbum("");
setArtist("");
setAlbumArtist("");
setCoverArt(defaultCoverArt);
return;
}
@@ -221,6 +269,8 @@ export default function ToobPlayerControl(
setCoverArt(coverArtUri);
setTitle(strTrack + metadata.title);
setAlbum(metadata.album);
setArtist(metadata.artist);
setAlbumArtist(metadata.albumArtist);
})
.catch((e)=>{
setTitle("#error" + e.message);
@@ -311,6 +361,10 @@ export default function ToobPlayerControl(
<Box sx={{ display: 'flex', alignItems: 'center', padding: "2px" }}>
<CoverImage>
<img style={{ opacity: pluginState === PluginState.Idle ? 0.3 : 1.0 }}
onDragStart={(e) => {
e.preventDefault();
}}
alt="Cover Art"
src={
coverArt
}
@@ -324,10 +378,10 @@ export default function ToobPlayerControl(
) : (
<div>
<Typography variant="body1" noWrap>
{effectiveTitle}
{titleLine}
</Typography>
<Typography variant="body2" noWrap sx={{}}>
{album}
{albumLine}
</Typography>
</div>
@@ -338,6 +392,16 @@ export default function ToobPlayerControl(
);
}
function loopButtonText() {
if (start === 0 && !loopEnable) {
return "Set loop";
} else if (loopEnable) {
return `${formatDuration(start)} [${formatDuration(loopStart)} - ${formatDuration(loopEnd)}]`;
} else {
return `Start: ${formatDuration(start)}`;
}
}
function getUiFileProperty(uri: string): UiFileProperty {
let pedalboardItem = model.pedalboard.get().getItem(props.instanceId);
let uiPlugin = model.getUiPlugin(pedalboardItem.uri);
@@ -389,7 +453,17 @@ export default function ToobPlayerControl(
});
setSliderValue(value);
}
function onStateChanged(value: State) {
setServerConnected(value === State.Ready);
}
useEffect(() => {
model.state.addOnChangedHandler(onStateChanged);
if (model.state.get() !== State.Ready) {
// wait for it.
return () => {
model.state.removeOnChangedHandler
}
}
let durationHandle = model.monitorPort(props.instanceId, "duration", 1.0 / 15,
(value) => {
setDuration(value);
@@ -400,6 +474,27 @@ export default function ToobPlayerControl(
setPosition(value);
}
);
// let startHandle = model.monitorPort(props.instanceId, "start", 1.0,
// (value) => {
// setStart(value);
// }
// );
// let loopStartHandle = model.monitorPort(props.instanceId, "loopStart", 1.0,
// (value) => {
// setLoopStart(value);
// }
// );
// let loopEnableHandle = model.monitorPort(props.instanceId, "loopEnable", 1.0,
// (value) => {
// setLoopEnable(value != 0);
// }
// );
// let loopEndHandle = model.monitorPort(props.instanceId, "loopEnd", 1.0,
// (value) => {
// setLoopEnd(value);
// }
// );
let pluginStateHandle = model.monitorPort(props.instanceId, "state", 1.0 / 1000.0,
(value) => {
setPluginState(value);
@@ -428,17 +523,23 @@ export default function ToobPlayerControl(
return () => {
model.state.removeOnChangedHandler(onStateChanged);
model.unmonitorPort(durationHandle);
model.unmonitorPort(positionHandle);
model.unmonitorPort(pluginStateHandle);
// model.unmonitorPort(loopEnableHandle);
// model.unmonitorPort(startHandle);
// model.unmonitorPort(loopStartHandle);
// model.unmonitorPort(loopEndHandle);
model.cancelMonitorPatchProperty(filePropertyHandle);
};
},
[]
[serverConnected]
);
const effectivePosition =
(pluginState !== PluginState.Idle) ? Math.min(position, duration) : 0.0;
const effectiveTitle = title !== "" ? title : pathFileNameOnly(audioFile);
const titleLine = title !== "" ? title : pathFileNameOnly(audioFile);
const albumLine = getAlbumLine(album,artist,albumArtist);
const paused = (pluginState === PluginState.Idle
|| pluginState === PluginState.CuePlayPaused
@@ -529,12 +630,24 @@ export default function ToobPlayerControl(
flex: "0 0 auto",
width: "100%",
display: 'flex',
alignItems: 'center',
alignItems: 'top',
justifyContent: 'space-between',
marginTop: -4,
}}
>
<TinyText>{formatDuration(dragging ? sliderValue : effectivePosition)}</TinyText>
<Button title="Loop" variant="dialogSecondary"
style={{flex: "0 1 auto",width: 200,
textTransform: "none", fontSize: "0.9rem", fontWeight: 500, padding: "4px 8px"
}}
startIcon= {(<RepeatIcon />)}
onClick={() => {
setShowLoopDialog(true);
}}
>
{loopButtonText()
}
</Button>
<TinyText>{formatDuration(duration)}</TinyText>
</div>
</div>
@@ -554,69 +667,7 @@ export default function ToobPlayerControl(
{
FilePanel()
}
<Slider
value={dragging ? sliderValue : effectivePosition}
min={0}
step={1}
max={duration}
onChange={(_, val) => {
let v = val as number;
setPosition(v);
setSliderValue(v);
}}
onChangeCommitted={(e, value) => {
let v = value as number;
OnSeek(v);
}}
onPointerDown={(e) => {
setDragging(true);
}}
onPointerUp={(e) => {
// setDragging(false);
}}
disabled={duration == 0}
sx={(t) => ({
width: "100%",
color: 'rgba(0,0,0,0.87)',
height: 4,
'& .MuiSlider-thumb': {
width: 8,
height: 8,
transition: '0.3s cubic-bezier(.47,1.64,.41,.8)',
'&::before': {
boxShadow: '0 2px 12px 0 rgba(0,0,0,0.4)',
},
'&:hover, &.Mui-focusVisible': {
boxShadow: `0px 0px 0px 8px ${'rgb(0 0 0 / 16%)'}`,
...t.applyStyles('dark', {
boxShadow: `0px 0px 0px 8px ${'rgb(255 255 255 / 16%)'}`,
}),
},
'&.Mui-active': {
width: 20,
height: 20,
},
},
'& .MuiSlider-rail': {
opacity: 0.28,
},
...t.applyStyles('dark', {
color: '#fff',
}),
})}
/>
<Box
sx={{
width: "100%",
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
mt: -2,
}}
>
<TinyText>{formatDuration(dragging ? sliderValue : effectivePosition)}</TinyText>
<TinyText>{formatDuration(duration)}</TinyText>
</Box>
{SliderCluster()}
{
ControlCluster()
}
@@ -683,11 +734,76 @@ export default function ToobPlayerControl(
<div id="toobPlayerViewFrame" style={{
width: '100%', height: "100%", overflow: 'hidden', position: 'relative',
}}>
<WallPaper />
{useWallpaper && (<WallPaper />)}
{
useHorizontalLayout ?
HorizontalWidget() : VerticalWidget()
}
{showLoopDialog && (
<LoopDialog isOpen={showLoopDialog}
sampleRate={
model.jackConfiguration.get().sampleRate == 0?
96000 :
model.jackConfiguration.get().sampleRate
}
timebase={timebase}
onTimebaseChange={(newTimebase) => {
setTimebase(newTimebase);
// model.setPatchProperty(
// props.instanceId,
// "timebase",
// newTimebase
// );
}}
onClose={() => {
setShowLoopDialog(false);
}}
onSetLoop={(start, loopEnable,loopStart, loopEnd) => {
let loop: LoopParameters = { start: start,
loopEnable: loopEnable,
loopStart: loopStart,
loopEnd: loopEnd };
let loopSettings = {
timebase: timebase,
loopParameters: loop
};
model.setPatchProperty(
props.instanceId,
"http://two-play.com/plugins/toob-player#loopSettings",
loopSettings.toString());
setStart(start);
// model.setPedalboardControl(
// props.instanceId,
// "start",
// start);
setLoopEnable(loopEnable);
// model.setPedalboardControl(
// props.instanceId,
// "loopEnable",
// loopEnable? 1.0: 0.0);
setLoopStart(loopStart);
// model.setPedalboardControl(
// props.instanceId,
// "loopStart",
// loopStart);
setLoopEnd(loopEnd);
// model.setPedalboardControl(
// props.instanceId,
// "loopEnd",
// loopEnd
// );
}}
start={start}
loopStart={loopStart}
loopEnable={loopEnable}
loopEnd={loopEnd}
duration={duration}
/>
)}
{showFileDialog && (
<FilePropertyDialog open={showFileDialog}
fileProperty={getUiFileProperty(AUDIO_FILE_PROPERTY_URI)}