diff --git a/src/modules/create/game-editor/editors/SoundEditor.tsx b/src/modules/create/game-editor/editors/SoundEditor.tsx index 7616c328..289e6464 100644 --- a/src/modules/create/game-editor/editors/SoundEditor.tsx +++ b/src/modules/create/game-editor/editors/SoundEditor.tsx @@ -1,10 +1,467 @@ -import React from "react"; -import { EditorProps } from "./EditorType"; +import React, { useState, useCallback, useEffect, useMemo, useRef } from "react"; +import { Box } from "@mui/material"; +import * as Tone from "tone"; +import { createMusic, MusicData, setNote, playMusicFromPosition } from "./SoundEditor/Music"; +import { registerCustomInstrument, stopAllSynths, NoteData } from "./SoundEditor/Note"; +import { InstrumentEditor, InstrumentConfig } from "./SoundEditor/InstrumentEditor"; +import { EditorProps } from "@modules/create/game-editor/editors/EditorType"; +import { MusicGrid } from "./SoundEditor/MusicGrid"; +import { InstrumentButtons } from "./SoundEditor/components/InstrumentButtons"; +import { MusicSelectionButtons } from "./SoundEditor/components/MusicSelectionButtons"; +import { ControlButtons } from "./SoundEditor/components/ControlButtons"; +import { defaultInstruments } from "./SoundEditor/constants/instruments"; +import { + SoundEditorRoot, + SoundEditorWrapper, + EditorContainer, + ErrorMessage, + NewInstrumentButton, +} from "./SoundEditor/styles/SoundEditor.styles"; + +export const SoundEditor: React.FC = ({ project }) => { + if (!project || !project.sound) { + return
Loading sound editor...
; + } + + const soundProvider = project.sound; + + const [musics, setMusics] = useState([]); + const [selectedMusicIndex, setSelectedMusicIndex] = useState(0); + const [currentMusic, setCurrentMusic] = useState(createMusic()); + const [customInstruments, setCustomInstruments] = useState>(new Map()); + const [instruments, setInstruments] = useState>(new Map(defaultInstruments)); + + const [currentInstrument, setCurrentInstrument] = useState("piano"); + const [activeCells, setActiveCells] = useState>(new Set()); + const [isMouseDown, setIsMouseDown] = useState(false); + const [startPosition, setStartPosition] = useState<[number, number]>([-1, -1]); + const [isPlaying, setIsPlaying] = useState(false); + const [loadingError] = useState(null); + const [playbackPosition, setPlaybackPosition] = useState(0); + const [playbackStartPosition, setPlaybackStartPosition] = useState(0); + const [isInstrumentEditorOpen, setIsInstrumentEditorOpen] = useState(false); + const [editingInstrument, setEditingInstrument] = useState<{ name: string; config: InstrumentConfig; originalKey?: string } | undefined>(undefined); + const [lastColumnWithNotes, setLastColumnWithNotes] = useState(0); + + const playbackTimeoutRef = useRef(null); + const shouldStopRef = useRef(false); + const isPlayingRef = useRef(false); + + useEffect(() => { + const handleUpdate = (updatedMusics: MusicData[]): void => { + setMusics(updatedMusics); + setCurrentMusic(updatedMusics[selectedMusicIndex] || createMusic()); + }; + + soundProvider.observe(handleUpdate); + return () => { + soundProvider.unobserve(handleUpdate); + }; + }, [soundProvider, selectedMusicIndex]); + + useEffect(() => { + if (musics.length > 0 && selectedMusicIndex >= 0 && selectedMusicIndex < musics.length) { + setCurrentMusic(musics[selectedMusicIndex] || createMusic()); + } + }, [selectedMusicIndex, musics]); + + useEffect(() => { + const handleCustomInstrumentsUpdate = (updatedInstruments: Map): void => { + setCustomInstruments(updatedInstruments); + const instrumentsMap = new Map(defaultInstruments); + updatedInstruments.forEach((_, name) => { + instrumentsMap.set(name, name.charAt(0).toUpperCase() + name.slice(1)); + }); + setInstruments(instrumentsMap); + }; + + soundProvider.observeCustomInstruments(handleCustomInstrumentsUpdate); + return () => { + soundProvider.unobserveCustomInstruments(handleCustomInstrumentsUpdate); + }; + }, [soundProvider]); + + const notesKey = useMemo(() => JSON.stringify(currentMusic.notes), [currentMusic.notes]); + + const calculateActiveCells = useCallback((notes: (NoteData[] | null)[]): Set => { + const activeCells = new Set(); + notes.forEach((column, columnIndex) => { + if (!column || !Array.isArray(column)) return; + column.forEach((note, rowIndex) => { + if (!note || note.note === "Nan") return; + const duration = note.duration || 1; + for (let k = 0; k < duration; k++) { + if (columnIndex + k < notes.length) { + activeCells.add(`${rowIndex}-${columnIndex + k}`); + } + } + }); + }); + return activeCells; + }, []); + + useEffect(() => { + const newActiveCells = calculateActiveCells(currentMusic.notes); + setActiveCells(newActiveCells); + }, [currentMusic, notesKey, calculateActiveCells]); + + useEffect(() => { + let lastColumn = -1; + const maxColumn = Math.max(currentMusic.length, 32); + for (let i = maxColumn - 1; i >= 0; i--) { + if (currentMusic.notes[i] && Array.isArray(currentMusic.notes[i]) && currentMusic.notes[i].length > 0) { + const hasNotes = currentMusic.notes[i].some(note => note && note.note !== "Nan"); + if (hasNotes) { + lastColumn = i; + break; + } + } + } + const newLastColumn = lastColumn >= 0 ? lastColumn + 1 : currentMusic.length; + setLastColumnWithNotes(newLastColumn); + }, [currentMusic, notesKey]); + + const gridCells = useMemo(() => { + return [...Array(24)].map((_, row) => + [...Array(32)].map((_, col) => { + const cellKey = `${row}-${col}`; + const isActive = activeCells.has(cellKey); + const isPlayingColumn = isPlaying && playbackPosition >= 0 && col === playbackPosition; + + let note = "Nan"; + let isNoteStart = false; + + if (currentMusic.notes[col] && Array.isArray(currentMusic.notes[col]) && currentMusic.notes[col][row]) { + const currentNote = currentMusic.notes[col][row]; + if (currentNote && currentNote.note !== "Nan") { + note = currentNote.note; + isNoteStart = true; + } + } + + return { + cellKey, + isActive, + row, + col, + note, + isNoteStart, + isPlayingColumn + }; + }) + ).flat(); + }, [activeCells, currentMusic, isPlaying, playbackPosition]); + + const handleMouseDown = useCallback((row: number, col: number) => { + if (isMouseDown) return; + setIsMouseDown(true); + setStartPosition([row, col]); + }, []); + + const handleMouseUp = useCallback((row: number, col: number) => { + if (!isMouseDown) return; + + setIsMouseDown(false); + + if (row === startPosition[0]) { + if (startPosition[1] === col) { + let noteStartCol = col; + let noteToRemove = null; + + if (currentMusic.notes[col] && currentMusic.notes[col][row] && currentMusic.notes[col][row].note !== "Nan") { + noteStartCol = col; + noteToRemove = currentMusic.notes[col][row]; + } + + if (noteToRemove) { + const newMusic = { ...currentMusic }; + newMusic.notes = newMusic.notes.map(column => column ? [...column] : []); + if (!newMusic.notes[noteStartCol]) { + newMusic.notes[noteStartCol] = []; + } + newMusic.notes[noteStartCol][row] = { note: "Nan", duration: 1, instrument: "" }; + + const newActiveCells = calculateActiveCells(newMusic.notes); + setActiveCells(newActiveCells); + soundProvider.setMusic(selectedMusicIndex, newMusic); + } else { + const newActiveCells = new Set(activeCells); + const cellKey = `${row}-${col}`; + newActiveCells.add(cellKey); + setActiveCells(newActiveCells); + + const updatedMusic = setNote(currentMusic, col, row, 1, currentInstrument); + soundProvider.setMusic(selectedMusicIndex, updatedMusic); + } + } else { + const newActiveCells = new Set(activeCells); + for (let i = Math.min(startPosition[1], col); i <= Math.max(startPosition[1], col); i++) { + const cellKey = `${row}-${i}`; + if (!newActiveCells.has(cellKey)) { + newActiveCells.add(cellKey); + } + } + setActiveCells(newActiveCells); + + const updatedMusic = setNote(currentMusic, startPosition[1], row, Math.max(1, Math.abs(startPosition[1] - col) + 1), currentInstrument); + soundProvider.setMusic(selectedMusicIndex, updatedMusic); + } + setStartPosition([-1, -1]); + } + }, [isMouseDown, startPosition, activeCells, currentInstrument, currentMusic]); + + const handleMouseOver = useCallback((row: number, col: number) => { + if (!isMouseDown) return; + + if (row === startPosition[0]) { + const newActiveCells = new Set(activeCells); + for (let i = Math.min(startPosition[1], col); i <= Math.max(startPosition[1], col); i++) { + const cellKey = `${row}-${i}`; + if (!newActiveCells.has(cellKey)) { + newActiveCells.add(cellKey); + } + } + setActiveCells(newActiveCells); + } + }, [isMouseDown, startPosition, activeCells]); + + const clearMusic = useCallback(() => { + setActiveCells(new Set()); + const clearedMusic = createMusic(); + soundProvider.setMusic(selectedMusicIndex, clearedMusic); + setPlaybackPosition(0); + setPlaybackStartPosition(0); + }, [soundProvider, selectedMusicIndex]); + + const handlePlay = useCallback(async () => { + if (isPlayingRef.current) return; + + shouldStopRef.current = false; + isPlayingRef.current = true; + setIsPlaying(true); + setPlaybackPosition(playbackStartPosition); + + const beatDuration = (60 / currentMusic.bpm) * 1000; + const actualEndColumn = lastColumnWithNotes; + const totalBeats = actualEndColumn - playbackStartPosition; + const totalDuration = totalBeats * beatDuration; + + try { + await playMusicFromPosition( + currentMusic, + playbackStartPosition, + (position) => { + if (shouldStopRef.current) { + return; + } + + setPlaybackPosition(position); + + if (position >= actualEndColumn) { + setPlaybackPosition(actualEndColumn); + setPlaybackStartPosition(0); + isPlayingRef.current = false; + setIsPlaying(false); + if (playbackTimeoutRef.current) { + clearTimeout(playbackTimeoutRef.current); + playbackTimeoutRef.current = null; + } + } + }, + () => { + }, + lastColumnWithNotes + ); + + playbackTimeoutRef.current = setTimeout(() => { + if (!shouldStopRef.current) { + setPlaybackPosition(0); + setPlaybackStartPosition(0); + isPlayingRef.current = false; + setIsPlaying(false); + } + playbackTimeoutRef.current = null; + }, totalDuration + 200); + } catch { + if (!shouldStopRef.current) { + setPlaybackPosition(0); + setPlaybackStartPosition(0); + isPlayingRef.current = false; + setIsPlaying(false); + } + if (playbackTimeoutRef.current) { + clearTimeout(playbackTimeoutRef.current); + playbackTimeoutRef.current = null; + } + } + }, [currentMusic, playbackStartPosition, lastColumnWithNotes]); + + const handleStop = useCallback(() => { + if (!isPlayingRef.current) return; + + shouldStopRef.current = true; + isPlayingRef.current = false; + setIsPlaying(false); + + stopAllSynths(); + + Tone.Transport.cancel(); + Tone.Transport.stop(); + + if (playbackTimeoutRef.current) { + clearTimeout(playbackTimeoutRef.current); + playbackTimeoutRef.current = null; + } + + setPlaybackStartPosition(playbackPosition); + }, [isPlaying, playbackPosition]); + + const handleSeek = useCallback((position: number) => { + if (isPlaying) return; + setPlaybackStartPosition(position); + setPlaybackPosition(position); + }, [isPlaying]); + + const loadStateFromMusic = useCallback((id: number) => { + const music = musics[id]; + setSelectedMusicIndex(id); + setPlaybackPosition(0); + setPlaybackStartPosition(0); + + const newActiveCells = calculateActiveCells(music.notes); + setActiveCells(newActiveCells); + }, [musics, calculateActiveCells]); + + useEffect(() => { + const storageKey = `soundEditor_customInstruments_${project.projectId}`; + try { + const stored = localStorage.getItem(storageKey); + if (stored) { + const parsed = JSON.parse(stored); + Object.entries(parsed).forEach(([name, config]: [string, unknown]) => { + soundProvider.setCustomInstrument(name, config as InstrumentConfig); + registerCustomInstrument(name, config); + }); + } + } catch { + void 0; + } + }, [soundProvider, project.projectId]); + + useEffect(() => { + if (customInstruments.size > 0) { + const storageKey = `soundEditor_customInstruments_${project.projectId}`; + try { + const toStore: Record = {}; + customInstruments.forEach((config, name) => { + toStore[name] = config; + }); + localStorage.setItem(storageKey, JSON.stringify(toStore)); + } catch { + void 0; + } + } + }, [customInstruments, project.projectId]); + + const handleSaveInstrument = useCallback((name: string, config: InstrumentConfig) => { + const normalizedName = name.toLowerCase().replace(/\s+/g, "-"); + const isEditing = editingInstrument !== undefined; + const oldKey = editingInstrument?.originalKey; + + if (isEditing && oldKey && oldKey !== normalizedName) { + soundProvider.deleteCustomInstrument(oldKey); + if (currentInstrument === oldKey) { + setCurrentInstrument(normalizedName); + } + } + + registerCustomInstrument(normalizedName, config); + soundProvider.setCustomInstrument(normalizedName, config); + setEditingInstrument(undefined); + }, [soundProvider, editingInstrument, currentInstrument]); + + const handleNewInstrument = useCallback(() => { + setEditingInstrument(undefined); + setIsInstrumentEditorOpen(true); + }, []); + + const handleCloseInstrumentEditor = useCallback(() => { + setIsInstrumentEditorOpen(false); + setEditingInstrument(undefined); + }, []); + + const handleEditInstrument = useCallback((instrument: string) => { + const config = customInstruments.get(instrument); + if (config) { + + const displayName = instruments.get(instrument) || instrument; + setEditingInstrument({ name: displayName, config, originalKey: instrument }); + setIsInstrumentEditorOpen(true); + } + }, [customInstruments, instruments]); + + const handleDeleteInstrument = useCallback((instrument: string) => { + if (window.confirm(`Are you sure you want to delete "${instruments.get(instrument) || instrument}"?`)) { + soundProvider.deleteCustomInstrument(instrument); + if (currentInstrument === instrument) { + setCurrentInstrument("piano"); + } + } + }, [soundProvider, instruments, currentInstrument]); -export const SoundEditor: React.FC = () => { return ( -

Sound Editor

+ setIsMouseDown(false)}> + + + + + + + New Instrument + + + + + + {loadingError && ( + + Warning: Failed to load some instruments. Using fallback audio. + + )} + + + ); }; - diff --git a/src/modules/editor/SoundEditor/InstrumentEditor.tsx b/src/modules/create/game-editor/editors/SoundEditor/InstrumentEditor.tsx similarity index 100% rename from src/modules/editor/SoundEditor/InstrumentEditor.tsx rename to src/modules/create/game-editor/editors/SoundEditor/InstrumentEditor.tsx diff --git a/src/modules/editor/SoundEditor/Music.ts b/src/modules/create/game-editor/editors/SoundEditor/Music.ts similarity index 100% rename from src/modules/editor/SoundEditor/Music.ts rename to src/modules/create/game-editor/editors/SoundEditor/Music.ts diff --git a/src/modules/create/game-editor/editors/SoundEditor/MusicGrid.tsx b/src/modules/create/game-editor/editors/SoundEditor/MusicGrid.tsx new file mode 100644 index 00000000..3d8cdb27 --- /dev/null +++ b/src/modules/create/game-editor/editors/SoundEditor/MusicGrid.tsx @@ -0,0 +1,48 @@ +import React from "react"; +import { ProgressBar } from "./components/ProgressBar"; +import { MusicGridProps, GridCellData } from "./types/MusicGrid.types"; +import { + ScrollableContainer, + GridWithProgressContainer, + GridContainer, + GridCell, +} from "./styles/MusicGrid.styles"; + +export type { GridCellData, MusicGridProps }; + +export const MusicGrid: React.FC = ({ + gridCells, + onMouseDown, + onMouseOver, + onMouseUp, + playbackPosition, + totalLength, + maxLength, + onSeek, +}) => ( + + + + + {gridCells.map((cell) => ( + onMouseDown(cell.row, cell.col)} + onMouseOver={() => onMouseOver(cell.row, cell.col)} + onMouseUp={() => onMouseUp(cell.row, cell.col)} + > + {cell.isNoteStart ? cell.note : ""} + + ))} + + + +); + diff --git a/src/modules/editor/SoundEditor/MusicManager.ts b/src/modules/create/game-editor/editors/SoundEditor/MusicManager.ts similarity index 100% rename from src/modules/editor/SoundEditor/MusicManager.ts rename to src/modules/create/game-editor/editors/SoundEditor/MusicManager.ts diff --git a/src/modules/editor/SoundEditor/Note.ts b/src/modules/create/game-editor/editors/SoundEditor/Note.ts similarity index 100% rename from src/modules/editor/SoundEditor/Note.ts rename to src/modules/create/game-editor/editors/SoundEditor/Note.ts diff --git a/src/modules/create/game-editor/editors/SoundEditor/components/ControlButtons.tsx b/src/modules/create/game-editor/editors/SoundEditor/components/ControlButtons.tsx new file mode 100644 index 00000000..875e02fb --- /dev/null +++ b/src/modules/create/game-editor/editors/SoundEditor/components/ControlButtons.tsx @@ -0,0 +1,18 @@ +import React from "react"; +import { ControlButtonsProps } from "../types/SoundEditor.types"; +import { ControlButtonsContainer, StyledButton } from "../styles/SoundEditor.styles"; + +export const ControlButtons: React.FC = ({ + isPlaying, + onPlay, + onStop, + onClear, +}) => ( + + + {isPlaying ? "Stop" : "Play"} + + Clear + +); + diff --git a/src/modules/create/game-editor/editors/SoundEditor/components/InstrumentButtons.tsx b/src/modules/create/game-editor/editors/SoundEditor/components/InstrumentButtons.tsx new file mode 100644 index 00000000..d0585c42 --- /dev/null +++ b/src/modules/create/game-editor/editors/SoundEditor/components/InstrumentButtons.tsx @@ -0,0 +1,49 @@ +import React from "react"; +import { Box } from "@mui/material"; +import { InstrumentButtonsProps } from "../types/SoundEditor.types"; +import { ButtonContainer, StyledButton } from "../styles/SoundEditor.styles"; + +export const InstrumentButtons: React.FC = ({ + instruments, + currentInstrument, + onInstrumentSelect, + customInstruments, + onEdit, + onDelete, +}) => { + const isCustomInstrument = customInstruments.has(currentInstrument); + + return ( + + + {Array.from(instruments.keys()).map((instrument) => ( + onInstrumentSelect(instrument)} + style={{ width: "100%" }} + > + {instruments.get(instrument)} + + ))} + + {isCustomInstrument && onEdit && onDelete && ( + + onEdit(currentInstrument)} + style={{ backgroundColor: "#4caf50", borderColor: "#45a049" }} + > + Edit + + onDelete(currentInstrument)} + style={{ backgroundColor: "#f44336", borderColor: "#da190b" }} + > + Delete + + + )} + + ); +}; + diff --git a/src/modules/create/game-editor/editors/SoundEditor/components/MusicSelectionButtons.tsx b/src/modules/create/game-editor/editors/SoundEditor/components/MusicSelectionButtons.tsx new file mode 100644 index 00000000..4b5f8cd1 --- /dev/null +++ b/src/modules/create/game-editor/editors/SoundEditor/components/MusicSelectionButtons.tsx @@ -0,0 +1,22 @@ +import React from "react"; +import { MusicSelectionButtonsProps } from "../types/SoundEditor.types"; +import { MusicSelectionContainer, MusicSelectionButton } from "../styles/SoundEditor.styles"; + +export const MusicSelectionButtons: React.FC = ({ + musics, + selectedMusicIndex, + onMusicSelect, +}) => ( + + {musics.map((_, index) => ( + onMusicSelect(index)} + > + {index + 1} + + ))} + +); + diff --git a/src/modules/create/game-editor/editors/SoundEditor/components/ProgressBar.tsx b/src/modules/create/game-editor/editors/SoundEditor/components/ProgressBar.tsx new file mode 100644 index 00000000..50803161 --- /dev/null +++ b/src/modules/create/game-editor/editors/SoundEditor/components/ProgressBar.tsx @@ -0,0 +1,38 @@ +import React from "react"; +import { ProgressBarProps } from "../types/SoundEditor.types"; +import { + ProgressBarContainer, + ProgressBarTrack, + ProgressBarFill, + ProgressBarThumb, +} from "../styles/ProgressBar.styles"; + +export const ProgressBar: React.FC = ({ + progress, + onSeek, + totalLength, + maxLength, +}) => { + const handleClick = (e: React.MouseEvent): void => { + const rect = e.currentTarget.getBoundingClientRect(); + const x = e.clientX - rect.left; + const columnWidth = rect.width / totalLength; + const column = Math.floor(x / columnWidth); + const position = Math.max(0, Math.min(maxLength - 1, column)); + onSeek(position); + }; + + const cappedProgress = Math.min(progress, maxLength - 1); + const columnCenterPosition = totalLength > 0 ? ((cappedProgress + 0.5) / totalLength) * 100 : 0; + const fillPercentage = totalLength > 0 && maxLength > 0 ? ((cappedProgress + 1) / maxLength) * (maxLength / totalLength) * 100 : 0; + + return ( + + + + + + + ); +}; + diff --git a/src/modules/create/game-editor/editors/SoundEditor/constants/instruments.ts b/src/modules/create/game-editor/editors/SoundEditor/constants/instruments.ts new file mode 100644 index 00000000..4d1ed98c --- /dev/null +++ b/src/modules/create/game-editor/editors/SoundEditor/constants/instruments.ts @@ -0,0 +1,9 @@ +export const defaultInstruments: Map = new Map([ + ["piano", "Piano"], + ["guitar", "Guitar"], + ["flute", "Flute"], + ["trumpet", "Trumpet"], + ["contrabass", "Contrabass"], + ["harmonica", "Harmonica"], +]); + diff --git a/src/modules/editor/SoundEditor/instruments/contrabass.json b/src/modules/create/game-editor/editors/SoundEditor/instruments/contrabass.json similarity index 100% rename from src/modules/editor/SoundEditor/instruments/contrabass.json rename to src/modules/create/game-editor/editors/SoundEditor/instruments/contrabass.json diff --git a/src/modules/editor/SoundEditor/instruments/flute.json b/src/modules/create/game-editor/editors/SoundEditor/instruments/flute.json similarity index 100% rename from src/modules/editor/SoundEditor/instruments/flute.json rename to src/modules/create/game-editor/editors/SoundEditor/instruments/flute.json diff --git a/src/modules/editor/SoundEditor/instruments/guitar.json b/src/modules/create/game-editor/editors/SoundEditor/instruments/guitar.json similarity index 100% rename from src/modules/editor/SoundEditor/instruments/guitar.json rename to src/modules/create/game-editor/editors/SoundEditor/instruments/guitar.json diff --git a/src/modules/editor/SoundEditor/instruments/harmonica.json b/src/modules/create/game-editor/editors/SoundEditor/instruments/harmonica.json similarity index 100% rename from src/modules/editor/SoundEditor/instruments/harmonica.json rename to src/modules/create/game-editor/editors/SoundEditor/instruments/harmonica.json diff --git a/src/modules/editor/SoundEditor/instruments/piano.json b/src/modules/create/game-editor/editors/SoundEditor/instruments/piano.json similarity index 100% rename from src/modules/editor/SoundEditor/instruments/piano.json rename to src/modules/create/game-editor/editors/SoundEditor/instruments/piano.json diff --git a/src/modules/editor/SoundEditor/instruments/trumpet.json b/src/modules/create/game-editor/editors/SoundEditor/instruments/trumpet.json similarity index 100% rename from src/modules/editor/SoundEditor/instruments/trumpet.json rename to src/modules/create/game-editor/editors/SoundEditor/instruments/trumpet.json diff --git a/src/modules/create/game-editor/editors/SoundEditor/styles/MusicGrid.styles.ts b/src/modules/create/game-editor/editors/SoundEditor/styles/MusicGrid.styles.ts new file mode 100644 index 00000000..7e8545a0 --- /dev/null +++ b/src/modules/create/game-editor/editors/SoundEditor/styles/MusicGrid.styles.ts @@ -0,0 +1,58 @@ +import { styled } from "@mui/material/styles"; + +export const ScrollableContainer = styled("div")(() => ({ + maxWidth: "45%", + maxHeight: "100%", + overflowX: "auto", + overflowY: "hidden", + display: "flex", + flexDirection: "column", + flexWrap: "nowrap", +})); + +export const GridWithProgressContainer = styled("div")(() => ({ + display: "flex", + flexDirection: "column", + alignItems: "flex-start", +})); + +export const GridContainer = styled("div")(() => ({ + width: "max-content", + display: "grid", + gridTemplateColumns: "repeat(32, 35px)", + gridTemplateRows: "repeat(24, 20px)", + marginTop: "1em", + rowGap: "2px", + backgroundColor: "#537D8D", + boxSizing: "border-box", + border: "3px solid #537D8D", +})); + +export const GridCell = styled("div")<{ isActive?: boolean; isPlayingColumn?: boolean }>(({ isActive, isPlayingColumn }) => ({ + width: "35px", + height: "20px", + boxSizing: "border-box", + backgroundColor: isPlayingColumn + ? "rgba(0, 188, 212, 0.4)" + : isActive + ? "#2a3c45" + : "#3a5863", + color: isActive ? "black" : "transparent", + userSelect: "none", + cursor: "pointer", + display: "flex", + alignItems: "center", + justifyContent: "center", + fontSize: isActive ? "12px" : "10px", + fontWeight: isActive ? "bold" : "normal", + borderLeft: isPlayingColumn ? "3px solid #00BCD4" : "none", + borderRight: isPlayingColumn ? "3px solid #00BCD4" : "none", + boxShadow: isPlayingColumn ? "0 0 8px rgba(0, 188, 212, 0.8)" : "none", + transition: isPlayingColumn ? "background-color 0.1s ease, box-shadow 0.1s ease" : "none", + "&:hover": { + backgroundColor: isPlayingColumn + ? "rgba(0, 188, 212, 0.5)" + : "#2a3c45", + }, +})); + diff --git a/src/modules/create/game-editor/editors/SoundEditor/styles/ProgressBar.styles.ts b/src/modules/create/game-editor/editors/SoundEditor/styles/ProgressBar.styles.ts new file mode 100644 index 00000000..37d94688 --- /dev/null +++ b/src/modules/create/game-editor/editors/SoundEditor/styles/ProgressBar.styles.ts @@ -0,0 +1,49 @@ +import { styled } from "@mui/material/styles"; + +export const ProgressBarContainer = styled("div")(() => ({ + width: "1120px", + height: "30px", + marginTop: "10px", + marginBottom: "10px", + position: "relative", + cursor: "pointer", + display: "flex", + alignItems: "center", + marginLeft: "3px", +})); + +export const ProgressBarTrack = styled("div")(() => ({ + width: "100%", + height: "8px", + backgroundColor: "#3a5863", + borderRadius: "4px", + position: "relative", + overflow: "hidden", +})); + +export const ProgressBarFill = styled("div")<{ width: string }>(({ width }) => ({ + height: "100%", + width: width, + backgroundColor: "#00BCD4", + borderRadius: "4px", + transition: "width 0.1s linear", +})); + +export const ProgressBarThumb = styled("div")<{ left: string }>(({ left }) => ({ + position: "absolute", + top: "50%", + left: left, + transform: "translate(-50%, -50%)", + width: "16px", + height: "16px", + backgroundColor: "#4c7280", + borderRadius: "50%", + cursor: "pointer", + border: "2px solid #7597a4", + boxShadow: "0 2px 4px rgba(0, 0, 0, 0.3)", + "&:hover": { + backgroundColor: "#3b5964", + transform: "translate(-50%, -50%) scale(1.2)", + }, +})); + diff --git a/src/modules/create/game-editor/editors/SoundEditor/styles/SoundEditor.styles.ts b/src/modules/create/game-editor/editors/SoundEditor/styles/SoundEditor.styles.ts new file mode 100644 index 00000000..1e684b6b --- /dev/null +++ b/src/modules/create/game-editor/editors/SoundEditor/styles/SoundEditor.styles.ts @@ -0,0 +1,83 @@ +import { Box } from "@mui/material"; +import { styled } from "@mui/material/styles"; + +export const ButtonContainer = styled(Box)(() => ({ + display: "grid", + gridTemplateColumns: "repeat(auto-fill, minmax(100px, 1fr))", + gap: "8px", + marginTop: "20px", + width: "100%", + maxWidth: "250px", +})); + +export const MusicSelectionContainer = styled(Box)(() => ({ + display: "grid", + gridTemplateColumns: "repeat(3, 1fr)", + gap: "4px", + marginTop: "20px", + width: "100%", + maxWidth: "150px", +})); + +export const StyledButton = styled("button")<{ selected?: boolean }>(({ selected }) => ({ + backgroundColor: selected ? "#4c7280" : "#537d8d", + color: "#ffffff", + padding: "8px 16px", + cursor: "pointer", + fontSize: "16px", + textAlign: "center", + textDecoration: "none", + display: "inline-block", + boxShadow: "none", + margin: "4px 2px", + fontFamily: "'Pixelify', 'Roboto', 'Helvetica', 'Arial', sans-serif", + borderRadius: "9.6px", + border: "2px solid #4c7280", + minWidth: "auto", + "&:hover": { + backgroundColor: "#3b5964", + }, +})); + +export const MusicSelectionButton = styled(StyledButton)(() => ({ + padding: "4px 8px", + fontSize: "12px", + minWidth: "auto", + width: "100%", +})); + +export const NewInstrumentButton = styled(StyledButton)(({ theme }) => ({ + marginTop: theme.spacing(1), +})); + +export const ControlButtonsContainer = styled("div")(() => ({ + display: "flex", + justifyContent: "space-around", + marginTop: "20px", +})); + +export const EditorContainer = styled("div")(() => ({ + display: "flex", + gap: "20px", + alignItems: "flex-start", + width: "100%", + maxWidth: "100%", + overflow: "hidden", +})); + +export const SoundEditorRoot = styled("div")(() => ({ + width: "100%", + overflow: "hidden", +})); + +export const ErrorMessage = styled("div")(() => ({ + color: "red", + textAlign: "center", + marginTop: "10px", +})); + +export const SoundEditorWrapper = styled("div")(() => ({ + width: "fit-content", + maxWidth: "100%", +})); + diff --git a/src/modules/create/game-editor/editors/SoundEditor/types/MusicGrid.types.ts b/src/modules/create/game-editor/editors/SoundEditor/types/MusicGrid.types.ts new file mode 100644 index 00000000..759c9b0f --- /dev/null +++ b/src/modules/create/game-editor/editors/SoundEditor/types/MusicGrid.types.ts @@ -0,0 +1,21 @@ +export interface GridCellData { + cellKey: string; + isActive: boolean; + row: number; + col: number; + note: string; + isNoteStart: boolean; + isPlayingColumn: boolean; +} + +export interface MusicGridProps { + gridCells: GridCellData[]; + onMouseDown: (row: number, col: number) => void; + onMouseOver: (row: number, col: number) => void; + onMouseUp: (row: number, col: number) => void; + playbackPosition: number; + totalLength: number; + maxLength: number; + onSeek: (position: number) => void; +} + diff --git a/src/modules/create/game-editor/editors/SoundEditor/types/SoundEditor.types.ts b/src/modules/create/game-editor/editors/SoundEditor/types/SoundEditor.types.ts new file mode 100644 index 00000000..aaceb7b0 --- /dev/null +++ b/src/modules/create/game-editor/editors/SoundEditor/types/SoundEditor.types.ts @@ -0,0 +1,31 @@ +import { MusicData } from "../Music"; + +export interface InstrumentButtonsProps { + instruments: Map; + currentInstrument: string; + onInstrumentSelect: (instrument: string) => void; + customInstruments: Set; + onEdit?: (instrument: string) => void; + onDelete?: (instrument: string) => void; +} + +export interface MusicSelectionButtonsProps { + musics: MusicData[]; + selectedMusicIndex: number; + onMusicSelect: (index: number) => void; +} + +export interface ControlButtonsProps { + isPlaying: boolean; + onPlay: () => void; + onStop: () => void; + onClear: () => void; +} + +export interface ProgressBarProps { + progress: number; + onSeek: (position: number) => void; + totalLength: number; + maxLength: number; +} + diff --git a/src/modules/editor/SoundEditor/MusicGrid.tsx b/src/modules/editor/SoundEditor/MusicGrid.tsx deleted file mode 100644 index ee2d1e59..00000000 --- a/src/modules/editor/SoundEditor/MusicGrid.tsx +++ /dev/null @@ -1,199 +0,0 @@ -import React from "react"; -import { styled } from "@mui/material/styles"; - -export interface GridCellData { - cellKey: string; - isActive: boolean; - row: number; - col: number; - note: string; - isNoteStart: boolean; - isPlayingColumn: boolean; -} - -const ScrollableContainer = styled("div")(() => ({ - maxWidth: "45%", - maxHeight: "100%", - overflowX: "auto", - overflowY: "hidden", - display: "flex", - flexDirection: "column", - flexWrap: "nowrap", -})); - -const GridWithProgressContainer = styled("div")(() => ({ - display: "flex", - flexDirection: "column", - alignItems: "flex-start", -})); - -const GridContainer = styled("div")(() => ({ - width: "max-content", - display: "grid", - gridTemplateColumns: "repeat(32, 35px)", - gridTemplateRows: "repeat(24, 20px)", - marginTop: "1em", - rowGap: "2px", - backgroundColor: "#537D8D", - boxSizing: "border-box", - border: "3px solid #537D8D", -})); - -const GridCell = styled("div")<{ isActive?: boolean; isPlayingColumn?: boolean }>(({ isActive, isPlayingColumn }) => ({ - width: "35px", - height: "20px", - boxSizing: "border-box", - backgroundColor: isPlayingColumn - ? "rgba(0, 188, 212, 0.4)" - : isActive - ? "#2a3c45" - : "#3a5863", - color: isActive ? "black" : "transparent", - userSelect: "none", - cursor: "pointer", - display: "flex", - alignItems: "center", - justifyContent: "center", - fontSize: isActive ? "12px" : "10px", - fontWeight: isActive ? "bold" : "normal", - borderLeft: isPlayingColumn ? "3px solid #00BCD4" : "none", - borderRight: isPlayingColumn ? "3px solid #00BCD4" : "none", - boxShadow: isPlayingColumn ? "0 0 8px rgba(0, 188, 212, 0.8)" : "none", - transition: isPlayingColumn ? "background-color 0.1s ease, box-shadow 0.1s ease" : "none", - "&:hover": { - backgroundColor: isPlayingColumn - ? "rgba(0, 188, 212, 0.5)" - : "#2a3c45", - }, -})); - -const ProgressBarContainer = styled("div")(() => ({ - width: "1120px", - height: "30px", - marginTop: "10px", - marginBottom: "10px", - position: "relative", - cursor: "pointer", - display: "flex", - alignItems: "center", - marginLeft: "3px", -})); - -const ProgressBarTrack = styled("div")(() => ({ - width: "100%", - height: "8px", - backgroundColor: "#3a5863", - borderRadius: "4px", - position: "relative", - overflow: "hidden", -})); - -const ProgressBarFill = styled("div")<{ width: string }>(({ width }) => ({ - height: "100%", - width: width, - backgroundColor: "#00BCD4", - borderRadius: "4px", - transition: "width 0.1s linear", -})); - -const ProgressBarThumb = styled("div")<{ left: string }>(({ left }) => ({ - position: "absolute", - top: "50%", - left: left, - transform: "translate(-50%, -50%)", - width: "16px", - height: "16px", - backgroundColor: "#4c7280", - borderRadius: "50%", - cursor: "pointer", - border: "2px solid #7597a4", - boxShadow: "0 2px 4px rgba(0, 0, 0, 0.3)", - "&:hover": { - backgroundColor: "#3b5964", - transform: "translate(-50%, -50%) scale(1.2)", - }, -})); - -interface ProgressBarProps { - progress: number; - onSeek: (position: number) => void; - totalLength: number; - maxLength: number; -} - -const ProgressBar: React.FC = ({ - progress, - onSeek, - totalLength, - maxLength, -}) => { - const handleClick = (e: React.MouseEvent): void => { - const rect = e.currentTarget.getBoundingClientRect(); - const x = e.clientX - rect.left; - const columnWidth = rect.width / totalLength; - const column = Math.floor(x / columnWidth); - const position = Math.max(0, Math.min(maxLength - 1, column)); - onSeek(position); - }; - - const cappedProgress = Math.min(progress, maxLength - 1); - const columnCenterPosition = totalLength > 0 ? ((cappedProgress + 0.5) / totalLength) * 100 : 0; - const fillPercentage = totalLength > 0 && maxLength > 0 ? ((cappedProgress + 1) / maxLength) * (maxLength / totalLength) * 100 : 0; - - return ( - - - - - - - ); -}; - -export interface MusicGridProps { - gridCells: GridCellData[]; - onMouseDown: (row: number, col: number) => void; - onMouseOver: (row: number, col: number) => void; - onMouseUp: (row: number, col: number) => void; - playbackPosition: number; - totalLength: number; - maxLength: number; - onSeek: (position: number) => void; -} - -export const MusicGrid: React.FC = ({ - gridCells, - onMouseDown, - onMouseOver, - onMouseUp, - playbackPosition, - totalLength, - maxLength, - onSeek, -}) => ( - - - - - {gridCells.map((cell) => ( - onMouseDown(cell.row, cell.col)} - onMouseOver={() => onMouseOver(cell.row, cell.col)} - onMouseUp={() => onMouseUp(cell.row, cell.col)} - > - {cell.isNoteStart ? cell.note : ""} - - ))} - - - -); - diff --git a/src/modules/editor/SoundEditor/SoundEditor.css b/src/modules/editor/SoundEditor/SoundEditor.css deleted file mode 100644 index 6141e8fb..00000000 --- a/src/modules/editor/SoundEditor/SoundEditor.css +++ /dev/null @@ -1,183 +0,0 @@ -.sound-editor-button-container { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); - gap: 8px; - margin-top: 20px; - width: 100%; - max-width: 250px; -} - -.sound-editor-music-selection-container { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 4px; - margin-top: 20px; - width: 100%; - max-width: 150px; -} - -.sound-editor-button { - background-color: #537d8d; - color: #ffffff; - padding: 8px 16px; - cursor: pointer; - font-size: 16px; - text-align: center; - text-decoration: none; - display: inline-block; - box-shadow: none; - margin: 4px 2px; - font-family: 'Pixelify', 'Roboto', 'Helvetica', 'Arial', sans-serif; - border-radius: 9.6px; - border: 2px solid #4c7280; - min-width: auto; -} - -.sound-editor-button:hover { - background-color: #3b5964; -} - -.sound-editor-button.selected { - background-color: #4c7280; -} - -.sound-editor-grid-container { - width: max-content; - display: grid; - grid-template-columns: repeat(32, 35px); - grid-template-rows: repeat(24, 20px); - margin-top: 1em; - row-gap: 2px; - background-color: #537D8D; - box-sizing: border-box; - border: 3px solid #537D8D; -} - -.sound-editor-grid-cell { - width: 35px; - height: 20px; - box-sizing: border-box; - background-color: #3a5863; - color: transparent; - user-select: none; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - font-size: 10px; - font-weight: normal; - border-left: none; - border-right: none; - box-shadow: none; - transition: none; -} - -.sound-editor-grid-cell.active { - background-color: #2a3c45; - color: black; - font-size: 12px; - font-weight: bold; -} - -.sound-editor-grid-cell.playing-column { - background-color: rgba(0, 188, 212, 0.4); - border-left: 3px solid #00BCD4; - border-right: 3px solid #00BCD4; - box-shadow: 0 0 8px rgba(0, 188, 212, 0.8); - transition: background-color 0.1s ease, box-shadow 0.1s ease; -} - -.sound-editor-grid-cell.playing-column:hover { - background-color: rgba(0, 188, 212, 0.5); -} - -.sound-editor-grid-cell:not(.playing-column):hover { - background-color: #2a3c45; -} - -.sound-editor-grid-cell.active.playing-column { - background-color: rgba(0, 188, 212, 0.4); -} - -.sound-editor-scrollable-container { - max-width: 45%; - max-height: 100%; - overflow-x: auto; - overflow-y: hidden; - display: flex; - flex-direction: column; - flex-wrap: nowrap; -} - -.sound-editor-grid-with-progress-container { - display: flex; - flex-direction: column; - align-items: flex-start; -} - -.sound-editor-container { - display: flex; - gap: 20px; - align-items: flex-start; - width: 100%; - max-width: 100%; - overflow: hidden; -} - -.sound-editor-control-buttons-container { - display: flex; - justify-content: space-around; - margin-top: 20px; -} - -.sound-editor-progress-bar-container { - width: 1120px; - height: 30px; - margin-top: 10px; - margin-bottom: 10px; - position: relative; - cursor: pointer; - display: flex; - align-items: center; - margin-left: 3px; -} - -.sound-editor-progress-bar-track { - width: 100%; - height: 8px; - background-color: #3a5863; - border-radius: 4px; - position: relative; - overflow: hidden; -} - -.sound-editor-progress-bar-fill { - height: 100%; - background-color: #00BCD4; - border-radius: 4px; - transition: width 0.1s linear; -} - -.sound-editor-progress-bar-thumb { - position: absolute; - top: 50%; - transform: translate(-50%, -50%); - width: 16px; - height: 16px; - background-color: #4c7280; - border-radius: 50%; - cursor: pointer; - border: 2px solid #7597a4; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); -} - -.sound-editor-progress-bar-thumb:hover { - background-color: #3b5964; - transform: translate(-50%, -50%) scale(1.2); -} - -.sound-editor-error-message { - color: red; - text-align: center; - margin-top: 10px; -} \ No newline at end of file diff --git a/src/modules/editor/SoundEditor/SoundEditor.tsx b/src/modules/editor/SoundEditor/SoundEditor.tsx deleted file mode 100644 index b4cda4ce..00000000 --- a/src/modules/editor/SoundEditor/SoundEditor.tsx +++ /dev/null @@ -1,644 +0,0 @@ -import React, { useState, useCallback, useEffect, useMemo, useRef } from "react"; -import { Box } from "@mui/material"; -import { styled } from "@mui/material/styles"; -import * as Tone from "tone"; -import { createMusic, MusicData, setNote, playMusicFromPosition } from "./Music"; -import { registerCustomInstrument, stopAllSynths, NoteData } from "./Note"; -import { InstrumentEditor, InstrumentConfig } from "./InstrumentEditor"; -import { EditorProps } from "@modules/create/game-editor/editors/EditorType"; -import { MusicGrid } from "./MusicGrid"; - -const ButtonContainer = styled(Box)(() => ({ - display: "grid", - gridTemplateColumns: "repeat(auto-fill, minmax(100px, 1fr))", - gap: "8px", - marginTop: "20px", - width: "100%", - maxWidth: "250px", -})); - -const MusicSelectionContainer = styled(Box)(() => ({ - display: "grid", - gridTemplateColumns: "repeat(3, 1fr)", - gap: "4px", - marginTop: "20px", - width: "100%", - maxWidth: "150px", -})); - -const StyledButton = styled("button")<{ selected?: boolean }>(({ selected }) => ({ - backgroundColor: selected ? "#4c7280" : "#537d8d", - color: "#ffffff", - padding: "8px 16px", - cursor: "pointer", - fontSize: "16px", - textAlign: "center", - textDecoration: "none", - display: "inline-block", - boxShadow: "none", - margin: "4px 2px", - fontFamily: "'Pixelify', 'Roboto', 'Helvetica', 'Arial', sans-serif", - borderRadius: "9.6px", - border: "2px solid #4c7280", - minWidth: "auto", - "&:hover": { - backgroundColor: "#3b5964", - }, -})); - -const MusicSelectionButton = styled(StyledButton)(() => ({ - padding: "4px 8px", - fontSize: "12px", - minWidth: "auto", - width: "100%", -})); - -const NewInstrumentButton = styled(StyledButton)(({ theme }) => ({ - marginTop: theme.spacing(1), -})); - -const ControlButtonsContainer = styled("div")(() => ({ - display: "flex", - justifyContent: "space-around", - marginTop: "20px", -})); - -const EditorContainer = styled("div")(() => ({ - display: "flex", - gap: "20px", - alignItems: "flex-start", - width: "100%", - maxWidth: "100%", - overflow: "hidden", -})); - -const SoundEditorRoot = styled("div")(() => ({ - width: "100%", - overflow: "hidden", -})); - -const ErrorMessage = styled("div")(() => ({ - color: "red", - textAlign: "center", - marginTop: "10px", -})); - -const SoundEditorWrapper = styled("div")(() => ({ - width: "fit-content", - maxWidth: "100%", -})); - -const defaultInstruments: Map = new Map([ - ["piano", "Piano"], - ["guitar", "Guitar"], - ["flute", "Flute"], - ["trumpet", "Trumpet"], - ["contrabass", "Contrabass"], - ["harmonica", "Harmonica"], -]); - -interface InstrumentButtonsProps { - instruments: Map; - currentInstrument: string; - onInstrumentSelect: (instrument: string) => void; - customInstruments: Set; - onEdit?: (instrument: string) => void; - onDelete?: (instrument: string) => void; -} - -const InstrumentButtons: React.FC = ({ - instruments, - currentInstrument, - onInstrumentSelect, - customInstruments, - onEdit, - onDelete, -}) => { - const isCustomInstrument = customInstruments.has(currentInstrument); - - return ( - - - {Array.from(instruments.keys()).map((instrument) => ( - onInstrumentSelect(instrument)} - style={{ width: "100%" }} - > - {instruments.get(instrument)} - - ))} - - {isCustomInstrument && onEdit && onDelete && ( - - onEdit(currentInstrument)} - style={{ backgroundColor: "#4caf50", borderColor: "#45a049" }} - > - Edit - - onDelete(currentInstrument)} - style={{ backgroundColor: "#f44336", borderColor: "#da190b" }} - > - Delete - - - )} - - ); -}; - -interface MusicSelectionButtonsProps { - musics: MusicData[]; - selectedMusicIndex: number; - onMusicSelect: (index: number) => void; -} - -const MusicSelectionButtons: React.FC = ({ - musics, - selectedMusicIndex, - onMusicSelect, -}) => ( - - {musics.map((_, index) => ( - onMusicSelect(index)} - > - {index + 1} - - ))} - -); - -interface ControlButtonsProps { - isPlaying: boolean; - onPlay: () => void; - onStop: () => void; - onClear: () => void; -} - -const ControlButtons: React.FC = ({ - isPlaying, - onPlay, - onStop, - onClear, -}) => ( - - - {isPlaying ? "Stop" : "Play"} - - Clear - -); - -export const SoundEditor: React.FC = ({ project }) => { - if (!project || !project.sound) { - return
Loading sound editor...
; - } - - const soundProvider = project.sound; - - const [musics, setMusics] = useState([]); - const [selectedMusicIndex, setSelectedMusicIndex] = useState(0); - const [currentMusic, setCurrentMusic] = useState(createMusic()); - const [customInstruments, setCustomInstruments] = useState>(new Map()); - const [instruments, setInstruments] = useState>(new Map(defaultInstruments)); - - const [currentInstrument, setCurrentInstrument] = useState("piano"); - const [activeCells, setActiveCells] = useState>(new Set()); - const [isMouseDown, setIsMouseDown] = useState(false); - const [startPosition, setStartPosition] = useState<[number, number]>([-1, -1]); - const [isPlaying, setIsPlaying] = useState(false); - const [loadingError] = useState(null); - const [playbackPosition, setPlaybackPosition] = useState(0); - const [playbackStartPosition, setPlaybackStartPosition] = useState(0); - const [isInstrumentEditorOpen, setIsInstrumentEditorOpen] = useState(false); - const [editingInstrument, setEditingInstrument] = useState<{ name: string; config: InstrumentConfig; originalKey?: string } | undefined>(undefined); - const [lastColumnWithNotes, setLastColumnWithNotes] = useState(0); - - const playbackTimeoutRef = useRef(null); - const shouldStopRef = useRef(false); - const isPlayingRef = useRef(false); - - useEffect(() => { - const handleUpdate = (updatedMusics: MusicData[]): void => { - setMusics(updatedMusics); - setCurrentMusic(updatedMusics[selectedMusicIndex] || createMusic()); - }; - - soundProvider.observe(handleUpdate); - return () => { - soundProvider.unobserve(handleUpdate); - }; - }, [soundProvider, selectedMusicIndex]); - - useEffect(() => { - if (musics.length > 0 && selectedMusicIndex >= 0 && selectedMusicIndex < musics.length) { - setCurrentMusic(musics[selectedMusicIndex] || createMusic()); - } - }, [selectedMusicIndex, musics]); - - useEffect(() => { - const handleCustomInstrumentsUpdate = (updatedInstruments: Map): void => { - setCustomInstruments(updatedInstruments); - const instrumentsMap = new Map(defaultInstruments); - updatedInstruments.forEach((_, name) => { - instrumentsMap.set(name, name.charAt(0).toUpperCase() + name.slice(1)); - }); - setInstruments(instrumentsMap); - }; - - soundProvider.observeCustomInstruments(handleCustomInstrumentsUpdate); - return () => { - soundProvider.unobserveCustomInstruments(handleCustomInstrumentsUpdate); - }; - }, [soundProvider]); - - const notesKey = useMemo(() => JSON.stringify(currentMusic.notes), [currentMusic.notes]); - - const calculateActiveCells = useCallback((notes: (NoteData[] | null)[]): Set => { - const activeCells = new Set(); - notes.forEach((column, columnIndex) => { - if (!column || !Array.isArray(column)) return; - column.forEach((note, rowIndex) => { - if (!note || note.note === "Nan") return; - const duration = note.duration || 1; - for (let k = 0; k < duration; k++) { - if (columnIndex + k < notes.length) { - activeCells.add(`${rowIndex}-${columnIndex + k}`); - } - } - }); - }); - return activeCells; - }, []); - - useEffect(() => { - const newActiveCells = calculateActiveCells(currentMusic.notes); - setActiveCells(newActiveCells); - }, [currentMusic, notesKey, calculateActiveCells]); - - useEffect(() => { - let lastColumn = -1; - const maxColumn = Math.max(currentMusic.length, 32); - for (let i = maxColumn - 1; i >= 0; i--) { - if (currentMusic.notes[i] && Array.isArray(currentMusic.notes[i]) && currentMusic.notes[i].length > 0) { - const hasNotes = currentMusic.notes[i].some(note => note && note.note !== "Nan"); - if (hasNotes) { - lastColumn = i; - break; - } - } - } - const newLastColumn = lastColumn >= 0 ? lastColumn + 1 : currentMusic.length; - setLastColumnWithNotes(newLastColumn); - }, [currentMusic, notesKey]); - - const gridCells = useMemo(() => { - return [...Array(24)].map((_, row) => - [...Array(32)].map((_, col) => { - const cellKey = `${row}-${col}`; - const isActive = activeCells.has(cellKey); - const isPlayingColumn = isPlaying && playbackPosition >= 0 && col === playbackPosition; - - let note = "Nan"; - let isNoteStart = false; - - if (currentMusic.notes[col] && Array.isArray(currentMusic.notes[col]) && currentMusic.notes[col][row]) { - const currentNote = currentMusic.notes[col][row]; - if (currentNote && currentNote.note !== "Nan") { - note = currentNote.note; - isNoteStart = true; - } - } - - return { - cellKey, - isActive, - row, - col, - note, - isNoteStart, - isPlayingColumn - }; - }) - ).flat(); - }, [activeCells, currentMusic, isPlaying, playbackPosition]); - - const handleMouseDown = useCallback((row: number, col: number) => { - if (isMouseDown) return; - setIsMouseDown(true); - setStartPosition([row, col]); - }, []); - - const handleMouseUp = useCallback((row: number, col: number) => { - if (!isMouseDown) return; - - setIsMouseDown(false); - - if (row === startPosition[0]) { - if (startPosition[1] === col) { - let noteStartCol = col; - let noteToRemove = null; - - if (currentMusic.notes[col] && currentMusic.notes[col][row] && currentMusic.notes[col][row].note !== "Nan") { - noteStartCol = col; - noteToRemove = currentMusic.notes[col][row]; - } - - if (noteToRemove) { - const newMusic = { ...currentMusic }; - newMusic.notes = newMusic.notes.map(column => column ? [...column] : []); - if (!newMusic.notes[noteStartCol]) { - newMusic.notes[noteStartCol] = []; - } - newMusic.notes[noteStartCol][row] = { note: "Nan", duration: 1, instrument: "" }; - - const newActiveCells = calculateActiveCells(newMusic.notes); - setActiveCells(newActiveCells); - soundProvider.setMusic(selectedMusicIndex, newMusic); - } else { - const newActiveCells = new Set(activeCells); - const cellKey = `${row}-${col}`; - newActiveCells.add(cellKey); - setActiveCells(newActiveCells); - - const updatedMusic = setNote(currentMusic, col, row, 1, currentInstrument); - soundProvider.setMusic(selectedMusicIndex, updatedMusic); - } - } else { - const newActiveCells = new Set(activeCells); - for (let i = Math.min(startPosition[1], col); i <= Math.max(startPosition[1], col); i++) { - const cellKey = `${row}-${i}`; - if (!newActiveCells.has(cellKey)) { - newActiveCells.add(cellKey); - } - } - setActiveCells(newActiveCells); - - const updatedMusic = setNote(currentMusic, startPosition[1], row, Math.max(1, Math.abs(startPosition[1] - col) + 1), currentInstrument); - soundProvider.setMusic(selectedMusicIndex, updatedMusic); - } - setStartPosition([-1, -1]); - } - }, [isMouseDown, startPosition, activeCells, currentInstrument, currentMusic]); - - const handleMouseOver = useCallback((row: number, col: number) => { - if (!isMouseDown) return; - - if (row === startPosition[0]) { - const newActiveCells = new Set(activeCells); - for (let i = Math.min(startPosition[1], col); i <= Math.max(startPosition[1], col); i++) { - const cellKey = `${row}-${i}`; - if (!newActiveCells.has(cellKey)) { - newActiveCells.add(cellKey); - } - } - setActiveCells(newActiveCells); - } - }, [isMouseDown, startPosition, activeCells]); - - const clearMusic = useCallback(() => { - setActiveCells(new Set()); - const clearedMusic = createMusic(); - soundProvider.setMusic(selectedMusicIndex, clearedMusic); - setPlaybackPosition(0); - setPlaybackStartPosition(0); - }, [soundProvider, selectedMusicIndex]); - - const handlePlay = useCallback(async () => { - if (isPlayingRef.current) return; - - shouldStopRef.current = false; - isPlayingRef.current = true; - setIsPlaying(true); - setPlaybackPosition(playbackStartPosition); - - const beatDuration = (60 / currentMusic.bpm) * 1000; - const actualEndColumn = lastColumnWithNotes; - const totalBeats = actualEndColumn - playbackStartPosition; - const totalDuration = totalBeats * beatDuration; - - try { - await playMusicFromPosition( - currentMusic, - playbackStartPosition, - (position) => { - if (shouldStopRef.current) { - return; - } - - setPlaybackPosition(position); - - if (position >= actualEndColumn) { - setPlaybackPosition(actualEndColumn); - setPlaybackStartPosition(0); - isPlayingRef.current = false; - setIsPlaying(false); - if (playbackTimeoutRef.current) { - clearTimeout(playbackTimeoutRef.current); - playbackTimeoutRef.current = null; - } - } - }, - () => { - }, - lastColumnWithNotes - ); - - playbackTimeoutRef.current = setTimeout(() => { - if (!shouldStopRef.current) { - setPlaybackPosition(0); - setPlaybackStartPosition(0); - isPlayingRef.current = false; - setIsPlaying(false); - } - playbackTimeoutRef.current = null; - }, totalDuration + 200); - } catch { - if (!shouldStopRef.current) { - setPlaybackPosition(0); - setPlaybackStartPosition(0); - isPlayingRef.current = false; - setIsPlaying(false); - } - if (playbackTimeoutRef.current) { - clearTimeout(playbackTimeoutRef.current); - playbackTimeoutRef.current = null; - } - } - }, [currentMusic, playbackStartPosition, lastColumnWithNotes]); - - const handleStop = useCallback(() => { - if (!isPlayingRef.current) return; - - shouldStopRef.current = true; - isPlayingRef.current = false; - setIsPlaying(false); - - stopAllSynths(); - - Tone.Transport.cancel(); - Tone.Transport.stop(); - - if (playbackTimeoutRef.current) { - clearTimeout(playbackTimeoutRef.current); - playbackTimeoutRef.current = null; - } - - setPlaybackStartPosition(playbackPosition); - }, [isPlaying, playbackPosition]); - - const handleSeek = useCallback((position: number) => { - if (isPlaying) return; - setPlaybackStartPosition(position); - setPlaybackPosition(position); - }, [isPlaying]); - - const loadStateFromMusic = useCallback((id: number) => { - const music = musics[id]; - setSelectedMusicIndex(id); - setPlaybackPosition(0); - setPlaybackStartPosition(0); - - const newActiveCells = calculateActiveCells(music.notes); - setActiveCells(newActiveCells); - }, [musics, calculateActiveCells]); - - useEffect(() => { - const storageKey = `soundEditor_customInstruments_${project.projectId}`; - try { - const stored = localStorage.getItem(storageKey); - if (stored) { - const parsed = JSON.parse(stored); - Object.entries(parsed).forEach(([name, config]: [string, unknown]) => { - soundProvider.setCustomInstrument(name, config as InstrumentConfig); - registerCustomInstrument(name, config); - }); - } - } catch { - void 0; - } - }, [soundProvider, project.projectId]); - - useEffect(() => { - if (customInstruments.size > 0) { - const storageKey = `soundEditor_customInstruments_${project.projectId}`; - try { - const toStore: Record = {}; - customInstruments.forEach((config, name) => { - toStore[name] = config; - }); - localStorage.setItem(storageKey, JSON.stringify(toStore)); - } catch { - void 0; - } - } - }, [customInstruments, project.projectId]); - - const handleSaveInstrument = useCallback((name: string, config: InstrumentConfig) => { - const normalizedName = name.toLowerCase().replace(/\s+/g, "-"); - const isEditing = editingInstrument !== undefined; - const oldKey = editingInstrument?.originalKey; - - if (isEditing && oldKey && oldKey !== normalizedName) { - soundProvider.deleteCustomInstrument(oldKey); - if (currentInstrument === oldKey) { - setCurrentInstrument(normalizedName); - } - } - - registerCustomInstrument(normalizedName, config); - soundProvider.setCustomInstrument(normalizedName, config); - setEditingInstrument(undefined); - }, [soundProvider, editingInstrument, currentInstrument]); - - const handleNewInstrument = useCallback(() => { - setEditingInstrument(undefined); - setIsInstrumentEditorOpen(true); - }, []); - - const handleCloseInstrumentEditor = useCallback(() => { - setIsInstrumentEditorOpen(false); - setEditingInstrument(undefined); - }, []); - - const handleEditInstrument = useCallback((instrument: string) => { - const config = customInstruments.get(instrument); - if (config) { - - const displayName = instruments.get(instrument) || instrument; - setEditingInstrument({ name: displayName, config, originalKey: instrument }); - setIsInstrumentEditorOpen(true); - } - }, [customInstruments, instruments]); - - const handleDeleteInstrument = useCallback((instrument: string) => { - if (window.confirm(`Are you sure you want to delete "${instruments.get(instrument) || instrument}"?`)) { - soundProvider.deleteCustomInstrument(instrument); - if (currentInstrument === instrument) { - setCurrentInstrument("piano"); - } - } - }, [soundProvider, instruments, currentInstrument]); - - return ( - setIsMouseDown(false)}> - - - - - - - New Instrument - - - - - - {loadingError && ( - - Warning: Failed to load some instruments. Using fallback audio. - - )} - - - - ); -}; diff --git a/src/providers/editors/SoundProvider.ts b/src/providers/editors/SoundProvider.ts index 143fc469..be4390b7 100644 --- a/src/providers/editors/SoundProvider.ts +++ b/src/providers/editors/SoundProvider.ts @@ -1,7 +1,7 @@ import * as Y from "yjs"; import { SoundProviderError } from "@errors/SoundProviderError.ts"; -import { MusicData, musicToJson, musicFromJson, createMusic } from "@modules/editor/SoundEditor/Music"; -import { InstrumentConfig } from "@modules/editor/SoundEditor/InstrumentEditor"; +import { MusicData, musicToJson, musicFromJson, createMusic } from "@modules/create/game-editor/editors/SoundEditor/Music"; +import { InstrumentConfig } from "@modules/create/game-editor/editors/SoundEditor/InstrumentEditor"; export type SoundProviderListener = (musics: MusicData[]) => void; export type CustomInstrumentsListener = (instruments: Map) => void;