Minor update
This commit is contained in:
65
README.md
65
README.md
@@ -1,42 +1,57 @@
|
|||||||
# sv
|
# Audio Visualizer
|
||||||
|
|
||||||
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
A real-time audio visualizer built with SvelteKit, Canvas API, and Web Audio API. Capture audio from your microphone, browser tab, or a local file and watch it come to life with reactive visuals, particles, and customizable themes.
|
||||||
|
|
||||||
## Creating a project
|
## Features
|
||||||
|
|
||||||
If you're seeing this, you've probably already done this step. Congrats!
|
- **4 Visualization Modes** — Circular, Bars, Wave, and Blob
|
||||||
|
- **3 Audio Sources** — Microphone, Browser Tab (Chrome/Edge), and Local File
|
||||||
|
- **Particle System** — Configurable density, size, rotation, and custom particle images
|
||||||
|
- **Audio Reactivity** — Sensitivity, smoothing, and bass emphasis controls
|
||||||
|
- **Visual Effects** — Shake, zoom, and glow intensity sliders
|
||||||
|
- **Color Presets & Custom Colors** — 6 built-in presets or pick your own gradient
|
||||||
|
- **Branding** — Custom logo with optional spin, title, and subtitle with positioning
|
||||||
|
- **Background** — Custom image, color picker, and vignette toggle
|
||||||
|
- **Theme System** — Save, load, import, and export themes as `.vslzr` files
|
||||||
|
- **Screenshot Export** — Save the current visualization as a PNG
|
||||||
|
- **Fullscreen Mode** — Immersive fullscreen with keyboard shortcut
|
||||||
|
- **i18n** — English and Estonian via Paraglide.js
|
||||||
|
- **Persistent Settings** — All settings saved to localStorage
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# create a new project
|
# Clone the repo
|
||||||
npx sv create my-app
|
git clone https://git.lapikud.ee/sass/audio-visualizer.git
|
||||||
```
|
cd audio-visualizer
|
||||||
|
|
||||||
To recreate this project with the same configuration:
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
```sh
|
# Start dev server
|
||||||
# recreate this project
|
|
||||||
npx sv create --template minimal --types ts --add tailwindcss="plugins:typography,forms" paraglide="languageTags:en, et+demo:no" --install npm audio-visualizer-v2
|
|
||||||
```
|
|
||||||
|
|
||||||
## Developing
|
|
||||||
|
|
||||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
npm run dev
|
npm run dev
|
||||||
|
|
||||||
# or start the server and open the app in a new browser tab
|
|
||||||
npm run dev -- --open
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Building
|
Open [http://localhost:5173](http://localhost:5173) in your browser.
|
||||||
|
|
||||||
To create a production version of your app:
|
## Keyboard Shortcuts
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
| --- | --- |
|
||||||
|
| `Space` | Play / Stop |
|
||||||
|
| `F` | Toggle Fullscreen |
|
||||||
|
| `H` | Hide / Show Sidebar |
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm run build
|
npm run build
|
||||||
|
npm run preview
|
||||||
```
|
```
|
||||||
|
|
||||||
You can preview the production build with `npm run preview`.
|
## Tech Stack
|
||||||
|
|
||||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
- **SvelteKit** + **Svelte 5** (runes)
|
||||||
|
- **Tailwind CSS v4**
|
||||||
|
- **Canvas API** + **Web Audio API**
|
||||||
|
- **Paraglide.js** for i18n
|
||||||
|
|||||||
19
package-lock.json
generated
19
package-lock.json
generated
@@ -15,6 +15,7 @@
|
|||||||
"@tailwindcss/forms": "^0.5.11",
|
"@tailwindcss/forms": "^0.5.11",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
"@types/node": "^25.2.3",
|
||||||
"svelte": "^5.49.2",
|
"svelte": "^5.49.2",
|
||||||
"svelte-check": "^4.3.6",
|
"svelte-check": "^4.3.6",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
@@ -1397,6 +1398,17 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "25.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.3.tgz",
|
||||||
|
"integrity": "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~7.16.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/trusted-types": {
|
"node_modules/@types/trusted-types": {
|
||||||
"version": "2.0.7",
|
"version": "2.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||||
@@ -2410,6 +2422,13 @@
|
|||||||
"node": ">=14.17"
|
"node": ">=14.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "7.16.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||||
|
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/unplugin": {
|
"node_modules/unplugin": {
|
||||||
"version": "2.3.11",
|
"version": "2.3.11",
|
||||||
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz",
|
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"@tailwindcss/forms": "^0.5.11",
|
"@tailwindcss/forms": "^0.5.11",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
"@types/node": "^25.2.3",
|
||||||
"svelte": "^5.49.2",
|
"svelte": "^5.49.2",
|
||||||
"svelte-check": "^4.3.6",
|
"svelte-check": "^4.3.6",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<script>
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from "svelte";
|
import { onMount, onDestroy } from "svelte";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -38,14 +38,26 @@
|
|||||||
const MAX_PARTICLES = 600;
|
const MAX_PARTICLES = 600;
|
||||||
const CANVAS_PAD = 2.5;
|
const CANVAS_PAD = 2.5;
|
||||||
|
|
||||||
let canvas;
|
interface Particle {
|
||||||
let ctx;
|
x: number;
|
||||||
let animationId;
|
y: number;
|
||||||
let audioContext;
|
vx: number;
|
||||||
let analyser;
|
vy: number;
|
||||||
let currentStream = null;
|
life: number;
|
||||||
let audioElement = null;
|
decay: number;
|
||||||
let audioSourceNode = null;
|
radius: number;
|
||||||
|
angle: number;
|
||||||
|
spin: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let canvas: HTMLCanvasElement;
|
||||||
|
let ctx: CanvasRenderingContext2D;
|
||||||
|
let animationId: number | undefined;
|
||||||
|
let audioContext: AudioContext | null = null;
|
||||||
|
let analyser: AnalyserNode | null = null;
|
||||||
|
let currentStream: MediaStream | null = null;
|
||||||
|
let audioElement: HTMLAudioElement | null = null;
|
||||||
|
let audioSourceNode: MediaElementAudioSourceNode | null = null;
|
||||||
let dataArray = new Uint8Array(128);
|
let dataArray = new Uint8Array(128);
|
||||||
let bufferLength = 128;
|
let bufferLength = 128;
|
||||||
|
|
||||||
@@ -59,11 +71,11 @@
|
|||||||
let smoothedValues = new Float32Array(Math.ceil(barCount / 2));
|
let smoothedValues = new Float32Array(Math.ceil(barCount / 2));
|
||||||
let smoothedScale = MIN_SCALE;
|
let smoothedScale = MIN_SCALE;
|
||||||
let smoothedLoudness = 0;
|
let smoothedLoudness = 0;
|
||||||
let visualizerWrapper;
|
let visualizerWrapper: HTMLDivElement;
|
||||||
let blobPhase = 0;
|
let blobPhase = 0;
|
||||||
|
|
||||||
// Particle image cache
|
// Particle image cache
|
||||||
let pImg = $state(null);
|
let pImg: HTMLImageElement | null = $state(null);
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (particleImage) {
|
if (particleImage) {
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
@@ -75,7 +87,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Particle system
|
// Particle system
|
||||||
let particles = [];
|
let particles: Particle[] = [];
|
||||||
|
|
||||||
// Recalculate angles when barCount changes
|
// Recalculate angles when barCount changes
|
||||||
let prevBarCount = barCount;
|
let prevBarCount = barCount;
|
||||||
@@ -105,7 +117,7 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function spawnParticles(loudness) {
|
function spawnParticles(loudness: number) {
|
||||||
if (!particlesEnabled) return;
|
if (!particlesEnabled) return;
|
||||||
const count = Math.floor(loudness * 8 * particleDensity);
|
const count = Math.floor(loudness * 8 * particleDensity);
|
||||||
const maxP = Math.floor(MAX_PARTICLES * particleDensity);
|
const maxP = Math.floor(MAX_PARTICLES * particleDensity);
|
||||||
@@ -113,7 +125,7 @@
|
|||||||
for (let j = 0; j < count; j++) {
|
for (let j = 0; j < count; j++) {
|
||||||
if (particles.length >= maxP) break;
|
if (particles.length >= maxP) break;
|
||||||
|
|
||||||
let p;
|
let p: Particle;
|
||||||
switch (vizMode) {
|
switch (vizMode) {
|
||||||
case "bars": {
|
case "bars": {
|
||||||
const halfBars = Math.ceil(barCount / 2);
|
const halfBars = Math.ceil(barCount / 2);
|
||||||
@@ -138,6 +150,8 @@
|
|||||||
life: 1.0,
|
life: 1.0,
|
||||||
decay: 0.008 + Math.random() * 0.02,
|
decay: 0.008 + Math.random() * 0.02,
|
||||||
radius: (2 + Math.random() * 5) * particleSize,
|
radius: (2 + Math.random() * 5) * particleSize,
|
||||||
|
angle: 0,
|
||||||
|
spin: 0,
|
||||||
};
|
};
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -161,6 +175,8 @@
|
|||||||
life: 1.0,
|
life: 1.0,
|
||||||
decay: 0.006 + Math.random() * 0.012,
|
decay: 0.006 + Math.random() * 0.012,
|
||||||
radius: (2 + Math.random() * 4) * particleSize,
|
radius: (2 + Math.random() * 4) * particleSize,
|
||||||
|
angle: 0,
|
||||||
|
spin: 0,
|
||||||
};
|
};
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -183,6 +199,8 @@
|
|||||||
life: 1.0,
|
life: 1.0,
|
||||||
decay: 0.006 + Math.random() * 0.015,
|
decay: 0.006 + Math.random() * 0.015,
|
||||||
radius: (2 + Math.random() * 6) * particleSize,
|
radius: (2 + Math.random() * 6) * particleSize,
|
||||||
|
angle: 0,
|
||||||
|
spin: 0,
|
||||||
};
|
};
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -199,6 +217,8 @@
|
|||||||
life: 1.0,
|
life: 1.0,
|
||||||
decay: 0.005 + Math.random() * 0.015,
|
decay: 0.005 + Math.random() * 0.015,
|
||||||
radius: (3 + Math.random() * 7) * particleSize,
|
radius: (3 + Math.random() * 7) * particleSize,
|
||||||
|
angle: 0,
|
||||||
|
spin: 0,
|
||||||
};
|
};
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -271,7 +291,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function initAudio() {
|
async function initAudio() {
|
||||||
audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
audioContext = new (window.AudioContext ||
|
||||||
|
(window as any).webkitAudioContext)();
|
||||||
if (audioContext.state === "suspended") {
|
if (audioContext.state === "suspended") {
|
||||||
await audioContext.resume();
|
await audioContext.resume();
|
||||||
}
|
}
|
||||||
@@ -298,7 +319,7 @@
|
|||||||
noiseSuppression: false,
|
noiseSuppression: false,
|
||||||
autoGainControl: false,
|
autoGainControl: false,
|
||||||
systemAudio: "include",
|
systemAudio: "include",
|
||||||
},
|
} as MediaTrackConstraints,
|
||||||
});
|
});
|
||||||
const audioTracks = stream.getAudioTracks();
|
const audioTracks = stream.getAudioTracks();
|
||||||
if (audioTracks.length === 0) {
|
if (audioTracks.length === 0) {
|
||||||
@@ -383,7 +404,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawCircular(halfBars, totalBars) {
|
function drawCircular(halfBars: number, totalBars: number) {
|
||||||
const maxBarHeight = size * 0.25;
|
const maxBarHeight = size * 0.25;
|
||||||
const barHeights = new Float32Array(totalBars);
|
const barHeights = new Float32Array(totalBars);
|
||||||
for (let i = 0; i < totalBars; i++) {
|
for (let i = 0; i < totalBars; i++) {
|
||||||
@@ -433,7 +454,7 @@
|
|||||||
ctx.fill("evenodd");
|
ctx.fill("evenodd");
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawBars(halfBars) {
|
function drawBars(halfBars: number) {
|
||||||
const totalBarsToShow = halfBars;
|
const totalBarsToShow = halfBars;
|
||||||
const barGap = 3;
|
const barGap = 3;
|
||||||
const totalWidth = canvas.width;
|
const totalWidth = canvas.width;
|
||||||
@@ -476,7 +497,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawWave(halfBars) {
|
function drawWave(halfBars: number) {
|
||||||
const totalWidth = canvas.width;
|
const totalWidth = canvas.width;
|
||||||
const maxAmp = canvas.height * 0.3;
|
const maxAmp = canvas.height * 0.3;
|
||||||
const startX = 0;
|
const startX = 0;
|
||||||
@@ -558,7 +579,7 @@
|
|||||||
ctx.shadowBlur = 0;
|
ctx.shadowBlur = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawBlob(halfBars) {
|
function drawBlob(halfBars: number) {
|
||||||
blobPhase += 0.008;
|
blobPhase += 0.008;
|
||||||
const points = 64;
|
const points = 64;
|
||||||
const maxDeform = size * 0.2;
|
const maxDeform = size * 0.2;
|
||||||
@@ -668,7 +689,10 @@
|
|||||||
draw();
|
draw();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to start audio:", err);
|
console.error("Failed to start audio:", err);
|
||||||
alert(err.message || "Failed to start audio. Check browser permissions.");
|
alert(
|
||||||
|
(err as Error).message ||
|
||||||
|
"Failed to start audio. Check browser permissions.",
|
||||||
|
);
|
||||||
if (audioContext) {
|
if (audioContext) {
|
||||||
audioContext.close().catch(() => {});
|
audioContext.close().catch(() => {});
|
||||||
audioContext = null;
|
audioContext = null;
|
||||||
@@ -697,7 +721,7 @@
|
|||||||
draw();
|
draw();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setAudioFile(file) {
|
export function setAudioFile(file: File) {
|
||||||
if (audioElement) {
|
if (audioElement) {
|
||||||
audioElement.pause();
|
audioElement.pause();
|
||||||
URL.revokeObjectURL(audioElement.src);
|
URL.revokeObjectURL(audioElement.src);
|
||||||
@@ -711,10 +735,10 @@
|
|||||||
fileCurrentTime = 0;
|
fileCurrentTime = 0;
|
||||||
fileDuration = 0;
|
fileDuration = 0;
|
||||||
audioElement.addEventListener("loadedmetadata", () => {
|
audioElement.addEventListener("loadedmetadata", () => {
|
||||||
fileDuration = audioElement.duration;
|
fileDuration = audioElement!.duration;
|
||||||
});
|
});
|
||||||
audioElement.addEventListener("timeupdate", () => {
|
audioElement.addEventListener("timeupdate", () => {
|
||||||
fileCurrentTime = audioElement.currentTime;
|
fileCurrentTime = audioElement!.currentTime;
|
||||||
});
|
});
|
||||||
audioElement.addEventListener("play", () => {
|
audioElement.addEventListener("play", () => {
|
||||||
filePlaying = true;
|
filePlaying = true;
|
||||||
@@ -732,7 +756,7 @@
|
|||||||
if (audioElement) audioElement.play();
|
if (audioElement) audioElement.play();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function seekAudio(time) {
|
export function seekAudio(time: number) {
|
||||||
if (audioElement) audioElement.currentTime = time;
|
if (audioElement) audioElement.currentTime = time;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -742,7 +766,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
ctx = canvas.getContext("2d");
|
ctx = canvas.getContext("2d")!;
|
||||||
precalculateAngles();
|
precalculateAngles();
|
||||||
updateSize();
|
updateSize();
|
||||||
window.addEventListener("resize", updateSize);
|
window.addEventListener("resize", updateSize);
|
||||||
|
|||||||
@@ -1,7 +1,56 @@
|
|||||||
<script>
|
<script lang="ts">
|
||||||
import * as m from "$lib/paraglide/messages.js";
|
import * as m from "$lib/paraglide/messages.js";
|
||||||
import { getLocale, setLocale, locales } from "$lib/paraglide/runtime.js";
|
import { getLocale, setLocale, locales } from "$lib/paraglide/runtime.js";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selectedPreset?: string;
|
||||||
|
logoUrl?: string;
|
||||||
|
title?: string;
|
||||||
|
subtitle?: string;
|
||||||
|
bgImage?: string;
|
||||||
|
bgColor?: string;
|
||||||
|
vignette?: boolean;
|
||||||
|
audioSource?: string;
|
||||||
|
customPrimary?: string;
|
||||||
|
customSecondary?: string;
|
||||||
|
useCustomColors?: boolean;
|
||||||
|
isListening?: boolean;
|
||||||
|
colorPresets?: Record<
|
||||||
|
string,
|
||||||
|
{ name: string; primary: string; secondary: string }
|
||||||
|
>;
|
||||||
|
onToggle?: () => void;
|
||||||
|
fileCurrentTime?: number;
|
||||||
|
fileDuration?: number;
|
||||||
|
filePlaying?: boolean;
|
||||||
|
fileName?: string;
|
||||||
|
vizMode?: string;
|
||||||
|
barCount?: number;
|
||||||
|
sensitivity?: number;
|
||||||
|
smoothing?: number;
|
||||||
|
bassEmphasis?: number;
|
||||||
|
particlesEnabled?: boolean;
|
||||||
|
particleDensity?: number;
|
||||||
|
shakeAmount?: number;
|
||||||
|
zoomIntensity?: number;
|
||||||
|
glowIntensity?: number;
|
||||||
|
logoSpin?: boolean;
|
||||||
|
logoSpinSpeed?: number;
|
||||||
|
particleImage?: string;
|
||||||
|
particleSize?: number;
|
||||||
|
particleRotation?: number;
|
||||||
|
titlePosition?: string;
|
||||||
|
savedThemes?: any[];
|
||||||
|
onscreenshot?: () => void;
|
||||||
|
onfullscreen?: () => void;
|
||||||
|
onreset?: () => void;
|
||||||
|
onaudiofile?: (file: File) => void;
|
||||||
|
onfilepause?: () => void;
|
||||||
|
onfileresume?: () => void;
|
||||||
|
onfileseek?: (time: number) => void;
|
||||||
|
onthemeschanged?: (themes: any[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
selectedPreset = $bindable("cyanPink"),
|
selectedPreset = $bindable("cyanPink"),
|
||||||
logoUrl = $bindable(""),
|
logoUrl = $bindable(""),
|
||||||
@@ -37,22 +86,22 @@
|
|||||||
particleSize = $bindable(1.0),
|
particleSize = $bindable(1.0),
|
||||||
particleRotation = $bindable(0),
|
particleRotation = $bindable(0),
|
||||||
titlePosition = $bindable("top"),
|
titlePosition = $bindable("top"),
|
||||||
savedThemes = $bindable([]),
|
savedThemes = $bindable<any[]>([]),
|
||||||
onscreenshot = () => {},
|
onscreenshot = () => {},
|
||||||
onfullscreen = () => {},
|
onfullscreen = () => {},
|
||||||
onreset = () => {},
|
onreset = () => {},
|
||||||
onaudiofile = (file) => {},
|
onaudiofile = (_file: File) => {},
|
||||||
onfilepause = () => {},
|
onfilepause = () => {},
|
||||||
onfileresume = () => {},
|
onfileresume = () => {},
|
||||||
onfileseek = (time) => {},
|
onfileseek = (_time: number) => {},
|
||||||
onthemeschanged = (themes) => {},
|
onthemeschanged = (_themes: any[]) => {},
|
||||||
} = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let fileInput = $state(null);
|
let fileInput: HTMLInputElement | null = $state(null);
|
||||||
let bgFileInput = $state(null);
|
let bgFileInput: HTMLInputElement | null = $state(null);
|
||||||
let audioFileInput = $state(null);
|
let audioFileInput: HTMLInputElement | null = $state(null);
|
||||||
let particleImgInput = $state(null);
|
let particleImgInput: HTMLInputElement | null = $state(null);
|
||||||
let themeFileInput = $state(null);
|
let themeFileInput: HTMLInputElement | null = $state(null);
|
||||||
let dragOver = $state(false);
|
let dragOver = $state(false);
|
||||||
let newThemeName = $state("");
|
let newThemeName = $state("");
|
||||||
|
|
||||||
@@ -62,7 +111,7 @@
|
|||||||
const supportsTabAudio = !isFirefox;
|
const supportsTabAudio = !isFirefox;
|
||||||
|
|
||||||
const vizModeKeys = ["circular", "bars", "wave", "blob"];
|
const vizModeKeys = ["circular", "bars", "wave", "blob"];
|
||||||
function vizModeName(key) {
|
function vizModeName(key: string) {
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case "circular":
|
case "circular":
|
||||||
return m.circular();
|
return m.circular();
|
||||||
@@ -77,48 +126,48 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function readFileAsDataUrl(file, callback) {
|
function readFileAsDataUrl(file: File, callback: (result: string) => void) {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = (e) => callback(e.target.result);
|
reader.onload = (e) => callback(e.target!.result as string);
|
||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTime(seconds) {
|
function formatTime(seconds: number) {
|
||||||
if (!seconds || !isFinite(seconds)) return "0:00";
|
if (!seconds || !isFinite(seconds)) return "0:00";
|
||||||
const min = Math.floor(seconds / 60);
|
const min = Math.floor(seconds / 60);
|
||||||
const sec = Math.floor(seconds % 60);
|
const sec = Math.floor(seconds % 60);
|
||||||
return `${min}:${sec.toString().padStart(2, "0")}`;
|
return `${min}:${sec.toString().padStart(2, "0")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleFileUpload(e) {
|
function handleFileUpload(e: Event) {
|
||||||
const file = e.target.files[0];
|
const file = (e.target as HTMLInputElement).files?.[0];
|
||||||
if (file) readFileAsDataUrl(file, (url) => (logoUrl = url));
|
if (file) readFileAsDataUrl(file, (url) => (logoUrl = url));
|
||||||
}
|
}
|
||||||
function handleParticleImgUpload(e) {
|
function handleParticleImgUpload(e: Event) {
|
||||||
const file = e.target.files[0];
|
const file = (e.target as HTMLInputElement).files?.[0];
|
||||||
if (file) readFileAsDataUrl(file, (url) => (particleImage = url));
|
if (file) readFileAsDataUrl(file, (url) => (particleImage = url));
|
||||||
}
|
}
|
||||||
function handleBgUpload(e) {
|
function handleBgUpload(e: Event) {
|
||||||
const file = e.target.files[0];
|
const file = (e.target as HTMLInputElement).files?.[0];
|
||||||
if (file) readFileAsDataUrl(file, (url) => (bgImage = url));
|
if (file) readFileAsDataUrl(file, (url) => (bgImage = url));
|
||||||
}
|
}
|
||||||
function handleAudioFile(e) {
|
function handleAudioFile(e: Event) {
|
||||||
const file = e.target.files[0];
|
const file = (e.target as HTMLInputElement).files?.[0];
|
||||||
if (file && file.type.startsWith("audio/")) {
|
if (file && file.type.startsWith("audio/")) {
|
||||||
audioSource = "file";
|
audioSource = "file";
|
||||||
onaudiofile(file);
|
onaudiofile(file);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function handleDrop(e) {
|
function handleDrop(e: DragEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
dragOver = false;
|
dragOver = false;
|
||||||
const file = e.dataTransfer.files[0];
|
const file = e.dataTransfer?.files[0];
|
||||||
if (file && file.type.startsWith("audio/")) {
|
if (file && file.type.startsWith("audio/")) {
|
||||||
audioSource = "file";
|
audioSource = "file";
|
||||||
onaudiofile(file);
|
onaudiofile(file);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function handleDragOver(e) {
|
function handleDragOver(e: DragEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
dragOver = true;
|
dragOver = true;
|
||||||
}
|
}
|
||||||
@@ -132,7 +181,7 @@
|
|||||||
if (bgFileInput) bgFileInput.value = "";
|
if (bgFileInput) bgFileInput.value = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectPreset(key) {
|
function selectPreset(key: string) {
|
||||||
selectedPreset = key;
|
selectedPreset = key;
|
||||||
useCustomColors = false;
|
useCustomColors = false;
|
||||||
}
|
}
|
||||||
@@ -140,8 +189,8 @@
|
|||||||
useCustomColors = true;
|
useCustomColors = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSeek(e) {
|
function handleSeek(e: Event) {
|
||||||
onfileseek(parseFloat(e.target.value));
|
onfileseek(parseFloat((e.target as HTMLInputElement).value));
|
||||||
}
|
}
|
||||||
function toggleFilePlayback() {
|
function toggleFilePlayback() {
|
||||||
if (filePlaying) onfilepause();
|
if (filePlaying) onfilepause();
|
||||||
@@ -185,7 +234,7 @@
|
|||||||
onthemeschanged(savedThemes);
|
onthemeschanged(savedThemes);
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadTheme(theme) {
|
function loadTheme(theme: any) {
|
||||||
selectedPreset = theme.selectedPreset ?? selectedPreset;
|
selectedPreset = theme.selectedPreset ?? selectedPreset;
|
||||||
logoUrl = theme.logoUrl ?? logoUrl;
|
logoUrl = theme.logoUrl ?? logoUrl;
|
||||||
title = theme.title ?? title;
|
title = theme.title ?? title;
|
||||||
@@ -214,12 +263,12 @@
|
|||||||
titlePosition = theme.titlePosition ?? titlePosition;
|
titlePosition = theme.titlePosition ?? titlePosition;
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteTheme(id) {
|
function deleteTheme(id: number) {
|
||||||
savedThemes = savedThemes.filter((t) => t.id !== id);
|
savedThemes = savedThemes.filter((t) => t.id !== id);
|
||||||
onthemeschanged(savedThemes);
|
onthemeschanged(savedThemes);
|
||||||
}
|
}
|
||||||
|
|
||||||
function exportTheme(theme) {
|
function exportTheme(theme: any) {
|
||||||
const data = JSON.stringify(theme, null, 2);
|
const data = JSON.stringify(theme, null, 2);
|
||||||
const blob = new Blob([data], { type: "application/json" });
|
const blob = new Blob([data], { type: "application/json" });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
@@ -230,13 +279,13 @@
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleThemeImport(e) {
|
function handleThemeImport(e: Event) {
|
||||||
const file = e.target.files[0];
|
const file = (e.target as HTMLInputElement).files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = (ev) => {
|
reader.onload = (ev) => {
|
||||||
try {
|
try {
|
||||||
const theme = JSON.parse(ev.target.result);
|
const theme = JSON.parse(ev.target!.result as string);
|
||||||
if (!theme.name) theme.name = file.name.replace(/\.vslzr$/, "");
|
if (!theme.name) theme.name = file.name.replace(/\.vslzr$/, "");
|
||||||
theme.id = Date.now();
|
theme.id = Date.now();
|
||||||
savedThemes = [...savedThemes, theme];
|
savedThemes = [...savedThemes, theme];
|
||||||
@@ -246,7 +295,7 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
reader.readAsText(file);
|
reader.readAsText(file);
|
||||||
e.target.value = "";
|
(e.target as HTMLInputElement).value = "";
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -332,7 +381,7 @@
|
|||||||
'file'
|
'file'
|
||||||
? 'bg-white/15 text-white'
|
? 'bg-white/15 text-white'
|
||||||
: 'bg-white/5 text-zinc-500 hover:bg-white/10'}"
|
: 'bg-white/5 text-zinc-500 hover:bg-white/10'}"
|
||||||
onclick={() => audioFileInput.click()}>{m.file()}</button
|
onclick={() => audioFileInput?.click()}>{m.file()}</button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -927,7 +976,7 @@
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
class="flex-1 px-3 py-1.5 bg-white/[0.06] border border-white/15 rounded-lg text-zinc-400 text-xs hover:bg-white/10 hover:text-white transition-all"
|
class="flex-1 px-3 py-1.5 bg-white/[0.06] border border-white/15 rounded-lg text-zinc-400 text-xs hover:bg-white/10 hover:text-white transition-all"
|
||||||
onclick={() => themeFileInput.click()}
|
onclick={() => themeFileInput?.click()}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<script>
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from "svelte";
|
import { onMount, onDestroy } from "svelte";
|
||||||
import { browser } from "$app/environment";
|
import { browser } from "$app/environment";
|
||||||
import AudioVisualizer from "$lib/AudioVisualizer.svelte";
|
import AudioVisualizer from "$lib/AudioVisualizer.svelte";
|
||||||
@@ -90,9 +90,9 @@
|
|||||||
let particleRotation = $state(saved.particleRotation);
|
let particleRotation = $state(saved.particleRotation);
|
||||||
let titlePosition = $state(saved.titlePosition);
|
let titlePosition = $state(saved.titlePosition);
|
||||||
let savedThemes = $state(loadThemes());
|
let savedThemes = $state(loadThemes());
|
||||||
let visualizerComponent = $state(null);
|
let visualizerComponent: any = $state(null);
|
||||||
let toggleHidden = $state(false);
|
let toggleHidden = $state(false);
|
||||||
let hideTimer;
|
let hideTimer: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
let fileCurrentTime = $state(0);
|
let fileCurrentTime = $state(0);
|
||||||
let fileDuration = $state(0);
|
let fileDuration = $state(0);
|
||||||
@@ -162,7 +162,7 @@
|
|||||||
} catch {}
|
} catch {}
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleThemesChanged(themes) {
|
function handleThemesChanged(themes: any[]) {
|
||||||
savedThemes = themes;
|
savedThemes = themes;
|
||||||
if (browser) {
|
if (browser) {
|
||||||
try {
|
try {
|
||||||
@@ -173,14 +173,14 @@
|
|||||||
|
|
||||||
function toggleListening() {
|
function toggleListening() {
|
||||||
if (isListening) {
|
if (isListening) {
|
||||||
visualizerComponent.stopListening();
|
visualizerComponent?.stopListening();
|
||||||
} else {
|
} else {
|
||||||
visualizerComponent.startListening();
|
visualizerComponent?.startListening();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleScreenshot() {
|
function handleScreenshot() {
|
||||||
const dataUrl = visualizerComponent.takeScreenshot();
|
const dataUrl = visualizerComponent?.takeScreenshot();
|
||||||
if (!dataUrl) return;
|
if (!dataUrl) return;
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.download = "visualizer-screenshot.png";
|
link.download = "visualizer-screenshot.png";
|
||||||
@@ -226,7 +226,7 @@
|
|||||||
titlePosition = defaults.titlePosition;
|
titlePosition = defaults.titlePosition;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleAudioFile(file) {
|
function handleAudioFile(file: File) {
|
||||||
if (file && visualizerComponent) {
|
if (file && visualizerComponent) {
|
||||||
if (isListening) visualizerComponent.stopListening();
|
if (isListening) visualizerComponent.stopListening();
|
||||||
audioSource = "file";
|
audioSource = "file";
|
||||||
@@ -240,15 +240,16 @@
|
|||||||
function handleFileResume() {
|
function handleFileResume() {
|
||||||
if (visualizerComponent) visualizerComponent.resumeAudio();
|
if (visualizerComponent) visualizerComponent.resumeAudio();
|
||||||
}
|
}
|
||||||
function handleFileSeek(time) {
|
function handleFileSeek(time: number) {
|
||||||
if (visualizerComponent) visualizerComponent.seekAudio(time);
|
if (visualizerComponent) visualizerComponent.seekAudio(time);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKeydown(e) {
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
if (
|
if (
|
||||||
e.target.tagName === "INPUT" ||
|
target.tagName === "INPUT" ||
|
||||||
e.target.tagName === "TEXTAREA" ||
|
target.tagName === "TEXTAREA" ||
|
||||||
e.target.isContentEditable
|
target.isContentEditable
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
switch (e.code) {
|
switch (e.code) {
|
||||||
@@ -306,7 +307,7 @@
|
|||||||
let currentColors = $derived(
|
let currentColors = $derived(
|
||||||
useCustomColors
|
useCustomColors
|
||||||
? { primary: customPrimary, secondary: customSecondary }
|
? { primary: customPrimary, secondary: customSecondary }
|
||||||
: colorPresets[selectedPreset],
|
: colorPresets[selectedPreset as keyof typeof colorPresets],
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user