diff --git a/app/api/handlers/audioEvents.client.ts b/app/api/handlers/audioEvents.client.ts index 5ea55b6..28d388e 100644 --- a/app/api/handlers/audioEvents.client.ts +++ b/app/api/handlers/audioEvents.client.ts @@ -8,13 +8,13 @@ import { type Stem, type Track, db, - updateTrack, + updateTrack } from '~/api/handlers/dbHandlers' import { type TrackState, audioState, mixState, - uiState, + uiState } from '~/api/models/appState.client' import { convertToSecs } from '~/utils/tableOps' @@ -35,7 +35,7 @@ const audioEvents = { initAudioContext: ({ trackId, stem, - media, + media }: { trackId: Track['id'] stem?: Stem @@ -258,7 +258,7 @@ const audioEvents = { audioEvents.initAudioContext({ trackId, - media: waveform.getMediaElement(), + media: waveform.getMediaElement() }) waveform.play() @@ -274,7 +274,7 @@ const audioEvents = { audioEvents.initAudioContext({ trackId, stem: stem as Stem, - media: stemWaveform.getMediaElement(), + media: stemWaveform.getMediaElement() }) stemWaveform.play() } @@ -412,7 +412,7 @@ const audioEvents = { // [left, right] @ 0% = [1, 0] 50% = [1, 1] 100% = [0, 1] const volumes = [ Math.min(1, 1 + Math.cos(sliderPercent * Math.PI)), - Math.min(1, 1 + Math.cos((1 - sliderPercent) * Math.PI)), + Math.min(1, 1 + Math.cos((1 - sliderPercent) * Math.PI)) ] if (mixState.tracks.length) { @@ -427,7 +427,7 @@ const audioEvents = { volume: trackVol = 1, stems, gainNode, - stemState, + stemState } = audioState[trackId] // if we have a stemType, this is a stem crossfader @@ -604,7 +604,7 @@ const audioEvents = { // Remove from stemsAnalyzing uiState.stemsAnalyzing.delete(trackId) - }, + } } export { audioEvents } diff --git a/app/api/handlers/audioHandlers.client.ts b/app/api/handlers/audioHandlers.client.ts index 1b2da5a..0248ccc 100644 --- a/app/api/handlers/audioHandlers.client.ts +++ b/app/api/handlers/audioHandlers.client.ts @@ -7,7 +7,7 @@ import { audioState, mixState, uiState, - userState, + userState } from '~/api/models/appState.client' import { errorHandler } from '~/utils/notifications' @@ -52,7 +52,7 @@ async function getTracksRecursively( size, type, fileHandle: fileOrDirectoryHandle, - dirHandle, + dirHandle }) } else if (fileOrDirectoryHandle.kind === 'directory') { for await (const handle of fileOrDirectoryHandle.values()) { @@ -93,7 +93,7 @@ async function getTracksRecursively( onCancel: () => { uiState.modal.openState = false uiState.processing = false - }, + } } return [] } @@ -105,7 +105,7 @@ const analyzeTracks = async (tracks: Track[]): Promise => { // Set analyzing state now to avoid tracks appearing with 'analyze' button uiState.analyzing = new Set([ ...uiState.analyzing, - ...tracks.map(track => track.id), + ...tracks.map(track => track.id) ]) // Return array of updated tracks @@ -135,7 +135,7 @@ const analyzeTracks = async (tracks: Track[]): Promise => { bpm: normalizedBpm, offset, sampleRate, - ...rest, + ...rest } const [trackWithId] = await putTracks([updatedTrack]) @@ -192,7 +192,7 @@ const getAudioDetails = async ( offset, bpm, duration, - sampleRate, + sampleRate } } @@ -239,7 +239,7 @@ const calcMarkers = async (trackId: Track['id']): Promise => { start: time, end: time, color: 'rgba(4, 146, 247, 0.757)', - drag: false, + drag: false }) } } diff --git a/app/api/handlers/dbHandlers.ts b/app/api/handlers/dbHandlers.ts index 02d014a..ee3cee6 100644 --- a/app/api/handlers/dbHandlers.ts +++ b/app/api/handlers/dbHandlers.ts @@ -3,17 +3,17 @@ import { useLiveQuery } from 'dexie-react-hooks' import { audioEvents } from '~/api/handlers/audioEvents.client' import { getPermission } from '~/api/handlers/fileHandlers' import { - __EFFECTS as EFFECTS, - type __Effect as Effect, - type __Mix as Mix, - type __MixSet as MixSet, - type __Mixpoint as Mixpoint, - __STEMS as STEMS, - type __Stem as Stem, - type __Track as Track, - type __TrackCache as TrackCache, - __db as db, -} from '~/api/models/__dbSchema' + EFFECTS, + type Effect, + type Mix, + type MixSet, + type Mixpoint, + STEMS, + type Stem, + type Track, + type TrackCache, + db +} from '~/api/models/appModels' import { audioState, mixState } from '~/api/models/appState.client' import { errorHandler } from '~/utils/notifications' @@ -22,7 +22,7 @@ const CACHE_LIMIT = 25 const storeTrackCache = async ({ id, file, - stems, + stems }: { id: TrackCache['id'] file?: TrackCache['file'] @@ -83,7 +83,7 @@ const putTracks = async (tracks: TrackIdOptional[]): Promise => { } const updatedTracks = await db.tracks.bulkPut(bulkTracks as Track[], { - allKeys: true, + allKeys: true }) return (await db.tracks.bulkGet(updatedTracks)) as Track[] } @@ -137,7 +137,7 @@ const putMixpoint = async ( return await db.mixpoints.put({ name, - effects, + effects }) } @@ -148,7 +148,7 @@ const putMixpoint = async ( const newMixpoint = { ...currentMixpoint, - ...{ name: name || currentMixpoint.name, effects: newEffects }, + ...{ name: name || currentMixpoint.name, effects: newEffects } } await db.mixpoints.put(newMixpoint, mixpointId) @@ -195,5 +195,5 @@ export { removeMix, addToMix, getTrackName, - storeTrackCache, + storeTrackCache } diff --git a/app/api/handlers/fileHandlers.ts b/app/api/handlers/fileHandlers.ts index 5a92baf..5552b82 100644 --- a/app/api/handlers/fileHandlers.ts +++ b/app/api/handlers/fileHandlers.ts @@ -4,14 +4,14 @@ import { type TrackCache, addToMix, db, - storeTrackCache, + storeTrackCache } from '~/api/handlers/dbHandlers' +import type { StemState } from '~/api/models/appModels' import { - type StemState, audioState, mixState, uiState, - userState, + userState } from '~/api/models/appState.client' import { errorHandler } from '~/utils/notifications' import { processTracks } from './audioHandlers.client' @@ -33,7 +33,7 @@ function showOpenFilePickerPolyfill(options: OpenFilePickerOptions) { getFile: async () => new Promise(resolve => { resolve(file) - }), + }) } }) ) @@ -159,7 +159,7 @@ const getStemsDirHandle = async (): Promise< const newStemsDirHandle = await window.showDirectoryPicker({ startIn: stemsDirHandle, id: 'stemsDir', - mode: 'readwrite', + mode: 'readwrite' }) if ( @@ -190,7 +190,7 @@ const validateTrackStemAccess = async ( // do we have access to the stem dir? try { const stemDirAccess = await stemsDirHandle.queryPermission({ - mode: 'readwrite', + mode: 'readwrite' }) if (stemDirAccess !== 'granted') return 'grantStemDirAccess' } catch (e) { diff --git a/app/api/handlers/stemHandler.ts b/app/api/handlers/stemHandler.ts index 8c37850..0a04337 100644 --- a/app/api/handlers/stemHandler.ts +++ b/app/api/handlers/stemHandler.ts @@ -4,7 +4,7 @@ import { type Stem, type Track, db, - storeTrackCache, + storeTrackCache } from '~/api/handlers/dbHandlers' import { getStemsDirHandle } from '~/api/handlers/fileHandlers' import { audioState } from '~/api/models/appState.client' @@ -51,7 +51,7 @@ const stemAudio = async (trackId: Track['id']) => { try { await fetch(ENDPOINT_URL, { method: 'PUT', - body: formData, + body: formData }) } catch (e) { return handleErr('Error uploading file for stem processing') @@ -69,7 +69,7 @@ const stemAudio = async (trackId: Track['id']) => { return new Promise((resolve, reject) => { const waitForStems = async (): Promise => { const res = await fetch(ENDPOINT_URL, { - method: 'HEAD', + method: 'HEAD' }) if (res.status === 202) { @@ -87,7 +87,7 @@ const stemAudio = async (trackId: Track['id']) => { return { name: `${FILENAME} - ${stem}.mp3`, type: stem, - file: await res.blob(), + file: await res.blob() } throw new Error(await res?.text()) }) @@ -118,7 +118,7 @@ const stemAudio = async (trackId: Track['id']) => { let stemsDirHandle: FileSystemDirectoryHandle try { stemsDirHandle = await dirHandle.getDirectoryHandle(`${FILENAME} - stems`, { - create: true, + create: true }) } catch (e) { throw errorHandler('Error creating directory for stems.') @@ -126,7 +126,7 @@ const stemAudio = async (trackId: Track['id']) => { for (const { name, type, file } of stems) { const stemFile = await stemsDirHandle.getFileHandle(name, { - create: true, + create: true }) const writable = await stemFile.createWritable() @@ -140,7 +140,7 @@ const stemAudio = async (trackId: Track['id']) => { // store stem in cache await storeTrackCache({ id: trackId, - stems: { [type]: { file } }, + stems: { [type]: { file } } }) } // give a couple of seconds before trying to render the stem waveform diff --git a/app/api/models/__dbSchema.ts b/app/api/models/appModels.ts similarity index 63% rename from app/api/models/__dbSchema.ts rename to app/api/models/appModels.ts index 98c986f..843b1f4 100644 --- a/app/api/models/__dbSchema.ts +++ b/app/api/models/appModels.ts @@ -1,7 +1,9 @@ // This file initializes Dexie (indexDB), defines the schema and creates tables // Be sure to create MIGRATIONS for any changes to SCHEMA! +import type { ButtonProps } from '@nextui-org/react' import Dexie from 'dexie' import type { Key } from 'react' +import type WaveSurfer from 'wavesurfer.js' // from https://dexie.org/docs/Typescript @@ -21,7 +23,7 @@ class MixpointDb extends Dexie { mixes: '++id, tracks', sets: '++id, mixes', trackCache: 'id', - appState: '', + appState: '' }) // example migration: // @@ -109,6 +111,7 @@ type MixSet = { const STEMS = ['drums', 'bass', 'vocals', 'other'] as const type Stem = (typeof STEMS)[number] +// TrackCache is a cache for track files, including stems type TrackCache = { id: Track['id'] file?: File @@ -144,18 +147,85 @@ type UserState = Partial<{ stemsDirHandle: FileSystemDirectoryHandle // local folder on file system to store stems }> +// AudioState is the working state of the mix, limited to the # of tracks in use, thereby not storing waveforms for all tracks +type AudioState = Partial<{ + waveform: WaveSurfer // must be a valtio ref() + playing: boolean + time: number + gainNode?: GainNode // gain controls actual loudness of track, must be a ref() + analyserNode?: AnalyserNode // analyzerNode is used for volumeMeter, must be a ref() + volume: number // volume is the crossfader value + volumeMeter?: number // value between 0 and 1 + stems: Stems + stemState: StemState + stemTimer: number +}> + +type Stems = { + [key in Stem]: Partial<{ + waveform: WaveSurfer // must be a valtio ref() + gainNode?: GainNode // gain controls actual loudness of stem, must be a ref() + analyserNode?: AnalyserNode // analyzerNode is used for volumeMeter, must be a ref() + volume: number // volume is the crossfader value + volumeMeter: number + mute: boolean + }> +} + +type StemState = + | 'selectStemDir' + | 'grantStemDirAccess' + | 'getStems' + | 'uploadingFile' + | 'processingStems' + | 'downloadingStems' + | 'ready' + | 'error' + +// ModalState is a generic handler for various modals, usually when doing something significant like deleting tracks +type ModalState = Partial<{ + openState: boolean + headerText: string + bodyText: string + confirmColor: ButtonProps['color'] + confirmText: string + onConfirm: () => void + onCancel: () => void +}> + +// UiState captures the state of various parts of the app, mostly the table, such as search value, which which rows are selected and track drawer open/closed state +type UiState = { + search: string | number + selected: Set // NextUI table uses string keys + rowsPerPage: number + page: number + showButton: number | null + openDrawer: boolean + dropZoneLoader: boolean + processing: boolean + analyzing: Set + stemsAnalyzing: Set + syncTimer: ReturnType | undefined + audioContext?: AudioContext + userEmail: string // email address + modal: ModalState +} + // Avoid having two files export same type names export type { - Track as __Track, - Mix as __Mix, - Mixpoint as __Mixpoint, - Effect as __Effect, - MixSet as __MixSet, - TrackCache as __TrackCache, - Stem as __Stem, + Track, + Mix, + Mixpoint, + Effect, + MixSet, + TrackCache, + Stem, + StemState, AppState, UserState, MixState, TrackState, + AudioState, + UiState } -export { db as __db, STEMS as __STEMS, EFFECTS as __EFFECTS } +export { db, STEMS, EFFECTS } diff --git a/app/api/models/appState.client.ts b/app/api/models/appState.client.ts index f340c79..ddf346e 100644 --- a/app/api/models/appState.client.ts +++ b/app/api/models/appState.client.ts @@ -1,11 +1,14 @@ // This file handles application state that may be persisted to local storage. -import type { ButtonProps } from '@nextui-org/react' -import type { Key } from 'react' import { proxy, snapshot } from 'valtio' import { devtools, proxySet, watch } from 'valtio/utils' -import type WaveSurfer from 'wavesurfer.js' -import { type Stem, type Track, db } from '~/api/handlers/dbHandlers' -import type { MixState, UserState } from '~/api/models/__dbSchema' +import { db } from '~/api/handlers/dbHandlers' +import type { + AudioState, + MixState, + Track, + UiState, + UserState +} from '~/api/models/appModels' import { Env } from '~/utils/env' // AudioState captures ephemeral state of a mix, while persistent state is stored in IndexedDB @@ -13,68 +16,7 @@ const audioState = proxy<{ [trackId: Track['id']]: AudioState }>({}) -type AudioState = Partial<{ - waveform: WaveSurfer // must be a valtio ref() - playing: boolean - time: number - gainNode?: GainNode // gain controls actual loudness of track, must be a ref() - analyserNode?: AnalyserNode // analyzerNode is used for volumeMeter, must be a ref() - volume: number // volume is the crossfader value - volumeMeter?: number // value between 0 and 1 - stems: Stems - stemState: StemState - stemTimer: number -}> - -type Stems = { - [key in Stem]: Partial<{ - waveform: WaveSurfer // must be a valtio ref() - gainNode?: GainNode // gain controls actual loudness of stem, must be a ref() - analyserNode?: AnalyserNode // analyzerNode is used for volumeMeter, must be a ref() - volume: number // volume is the crossfader value - volumeMeter: number - mute: boolean - }> -} - -type StemState = - | 'selectStemDir' - | 'grantStemDirAccess' - | 'getStems' - | 'uploadingFile' - | 'processingStems' - | 'downloadingStems' - | 'ready' - | 'error' - -// ModalState is a generic handler for various modals, usually when doing something significant like deleting tracks -type ModalState = Partial<{ - openState: boolean - headerText: string - bodyText: string - confirmColor: ButtonProps['color'] - confirmText: string - onConfirm: () => void - onCancel: () => void -}> - -// App captures the state of various parts of the app, mostly the table, such as search value, which which rows are selected and track drawer open/closed state -const uiState = proxy<{ - search: string | number - selected: Set // NextUI table uses string keys - rowsPerPage: number - page: number - showButton: number | null - openDrawer: boolean - dropZoneLoader: boolean - processing: boolean - analyzing: Set - stemsAnalyzing: Set - syncTimer: ReturnType | undefined - audioContext?: AudioContext - userEmail: string // email address - modal: ModalState -}>({ +const uiState = proxy({ search: '', selected: proxySet(), rowsPerPage: 10, @@ -87,7 +29,7 @@ const uiState = proxy<{ stemsAnalyzing: proxySet(), syncTimer: undefined, userEmail: '', - modal: { openState: false }, + modal: { openState: false } }) // Pull latest persistent state from Dexie and populate Valtio store @@ -135,4 +77,3 @@ const initAudioState = async () => { initAudioState() export { uiState, audioState, mixState, userState } -export type { AudioState, StemState, Stems, MixState } diff --git a/app/components/layout/Main.tsx b/app/components/layout/Main.tsx index 5b8e688..408fdf6 100644 --- a/app/components/layout/Main.tsx +++ b/app/components/layout/Main.tsx @@ -1,42 +1,42 @@ -import { useEffect, useState } from 'react' -import { subscribe } from 'valtio' -import { mixState } from '~/api/models/appState.client' -import Heart from '~/components/layout/HeartIcon' -import LeftNav from '~/components/layout/LeftNav' -import MixView from '~/components/mixes/MixView' -import TrackDrawer from '~/components/tracks/TrackDrawer' -import TrackTable from '~/components/tracks/TrackTable' - -const Main: React.FunctionComponent = () => { - const mixVisible = mixState.tracks?.filter(t => t).length > 0 - const [mixView, setMixView] = useState(mixVisible) - - useEffect( - () => - subscribe(mixState.tracks, () => { - // this is needed otherwise changes in mixState.tracks will force a refresh of Main - const mixVisible = mixState.tracks?.filter(t => t).length > 0 - setMixView(mixVisible) - }), - [] - ) - - return mixView ? ( - <> - - - - ) : ( - <> -
- -
- -
-
- - - ) -} - -export { Main as default } +import { useEffect, useState } from 'react' +import { subscribe } from 'valtio' +import { mixState } from '~/api/models/appState.client' +import Heart from '~/components/layout/HeartIcon' +import LeftNav from '~/components/layout/LeftNav' +import MixView from '~/components/mixes/MixView' +import TrackDrawer from '~/components/tracks/TrackDrawer' +import TrackTable from '~/components/tracks/TrackTable' + +const Main: React.FunctionComponent = () => { + const mixVisible = mixState.tracks?.filter(t => t).length > 0 + const [mixView, setMixView] = useState(mixVisible) + + useEffect( + () => + subscribe(mixState.tracks, () => { + // this is needed otherwise changes in mixState.tracks will force a refresh of Main + const mixVisible = mixState.tracks?.filter(t => t).length > 0 + setMixView(mixVisible) + }), + [] + ) + + return mixView ? ( + <> + + + + ) : ( + <> +
+ +
+ +
+
+ + + ) +} + +export { Main as default } diff --git a/app/components/mixes/StemAccessButton.client.tsx b/app/components/mixes/StemAccessButton.client.tsx index 9c7b8e4..808978f 100644 --- a/app/components/mixes/StemAccessButton.client.tsx +++ b/app/components/mixes/StemAccessButton.client.tsx @@ -8,7 +8,8 @@ import { validateTrackStemAccess } from '~/api/handlers/fileHandlers' import { stemAudio } from '~/api/handlers/stemHandler' -import { type StemState, audioState } from '~/api/models/appState.client' +import type { StemState } from '~/api/models/appModels' +import { audioState } from '~/api/models/appState.client' import { OfflineDownloadIcon, RuleFolderIcon, diff --git a/app/entry.server.tsx b/app/entry.server.tsx index c74fb87..cbc0a31 100644 --- a/app/entry.server.tsx +++ b/app/entry.server.tsx @@ -1,5 +1,5 @@ import { HandleError } from '@highlight-run/remix/server' -import { type EntryContext } from '@vercel/remix' +import type { EntryContext } from '@vercel/remix' import { renderHeadToString } from 'remix-island' import { Head } from './root' diff --git a/app/routes/_index.tsx b/app/routes/_index.tsx index 2692b7e..9fc7eb0 100644 --- a/app/routes/_index.tsx +++ b/app/routes/_index.tsx @@ -1,19 +1,19 @@ -import Layout from '~/components/layout/Layout' -import { errorHandler } from '~/utils/notifications' - -// detect mobile device -const userAgent = typeof window !== 'undefined' && window.navigator?.userAgent -if ( - userAgent && - // biome-ignore lint/suspicious/noExplicitAny: - ((/iPad|iPhone|iPod/.test(userAgent) && !(window as any).MSStream) || - /android/i.test(userAgent)) -) { - setTimeout( - () => errorHandler('Mixpoint is for desktops only (for now)'), - 2000 - ) -} - -const Index = () => -export { Index as default } +import Layout from '~/components/layout/Layout' +import { errorHandler } from '~/utils/notifications' + +// detect mobile device +const userAgent = typeof window !== 'undefined' && window.navigator?.userAgent +if ( + userAgent && + // biome-ignore lint/suspicious/noExplicitAny: + ((/iPad|iPhone|iPod/.test(userAgent) && !(window as any).MSStream) || + /android/i.test(userAgent)) +) { + setTimeout( + () => errorHandler('Mixpoint is for desktops only (for now)'), + 2000 + ) +} + +const Index = () => +export { Index as default } diff --git a/postcss.config.mjs b/postcss.config.mjs index 01bf743..e5904c1 100644 --- a/postcss.config.mjs +++ b/postcss.config.mjs @@ -1,5 +1,5 @@ export default { plugins: { - tailwindcss: {}, - }, -}; + tailwindcss: {} + } +} diff --git a/tailwind.config.ts b/tailwind.config.ts index 604b0d3..a7cf5b3 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -1,7 +1,7 @@ import { nextui } from '@nextui-org/react' import type { Config } from 'tailwindcss' -export default ({ +export default { content: [ './app/**/*.{js,jsx,ts,tsx}', './node_modules/@nextui-org/theme/dist/**/*.{js,ts,jsx,tsx}' @@ -37,4 +37,4 @@ export default ({ } }) ] -} satisfies Config) +} satisfies Config diff --git a/tsconfig.json b/tsconfig.json index c1e7d2b..b86e441 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,44 +1,34 @@ -{ - "compilerOptions": { - // latest features - "lib": [ - "DOM", - "DOM.Iterable", - "ESNext" - ], - "target": "ESNext", - "module": "ESNext", - "moduleDetection": "force", - "jsx": "react-jsx", - "allowJs": true, - // source maps - "sourceMap": true, - "inlineSources": true, - "isolatedModules": true, - // bundler - "moduleResolution": "Bundler", - "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, - "noEmit": true, - "resolveJsonModule": true, - // best practices - "strict": true, - "skipLibCheck": true, - "baseUrl": ".", - "paths": { - "~/*": [ - "./app/*" - ] - }, - // other checks - "forceConsistentCasingInFileNames": true, - "noImplicitAny": true, - "strictNullChecks": true, - "esModuleInterop": true - }, - "include": [ - "**/*.ts", - "**/*.tsx", - "env.d.ts" - ] -} \ No newline at end of file +{ + "compilerOptions": { + // latest features + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + // source maps + "sourceMap": true, + "inlineSources": true, + "isolatedModules": true, + // bundler + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "resolveJsonModule": true, + // best practices + "strict": true, + "skipLibCheck": true, + "baseUrl": ".", + "paths": { + "~/*": ["./app/*"] + }, + // other checks + "forceConsistentCasingInFileNames": true, + "noImplicitAny": true, + "strictNullChecks": true, + "esModuleInterop": true + }, + "include": ["**/*.ts", "**/*.tsx", "env.d.ts"] +}