Small revamps and design changes to visualizer, now in Estonian

This commit is contained in:
AlacrisDevs
2026-02-17 23:39:59 +02:00
parent 89bbb0ecc7
commit 2d1e3a401f
30 changed files with 4391 additions and 3311 deletions

45
.gitignore vendored
View File

@@ -1,31 +1,26 @@
# Dependencies node_modules
node_modules/
# Build output # Output
dist/ .output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# Vite # OS
.vite/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea/
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# OS files
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# Logs # Env
*.log
npm-debug.log*
# Environment files
.env .env
.env.local .env.*
.env.*.local !.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# Paraglide
src/lib/paraglide
project.inlang/cache/

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
engine-strict=true

5
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,5 @@
{
"files.associations": {
"*.css": "tailwindcss"
}
}

42
README.md Normal file
View File

@@ -0,0 +1,42 @@
# sv
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```sh
# create a new project
npx sv create my-app
```
To recreate this project with the same configuration:
```sh
# 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
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```sh
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.

View File

@@ -1,31 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Audio Visualizer</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: #0a0a0a;
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
</style>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

57
messages/en.json Normal file
View File

@@ -0,0 +1,57 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"audio_source": "Audio Source",
"stop": "Stop",
"start": "Start",
"mic": "Mic",
"tab": "Tab",
"file": "File",
"tab_hint": "Click Start, pick a tab playing music, and check",
"tab_hint_bold": "Share tab audio",
"drop_audio": "Drop audio file here",
"file_loaded": "File loaded. Click Start to begin playback.",
"themes": "Themes",
"theme_name_placeholder": "Theme name...",
"save": "Save",
"visualization": "Visualization",
"circular": "Circular",
"bars": "Bars",
"wave": "Wave",
"blob": "Blob",
"bars_count": "Bars",
"reactivity": "Reactivity",
"sensitivity": "Sensitivity",
"smoothing": "Smoothing",
"bass_emphasis": "Bass Emphasis",
"effects": "Effects",
"shake": "Shake",
"zoom": "Zoom",
"glow": "Glow",
"particles": "Particles",
"enable_particles": "Enable Particles",
"density": "Density",
"size": "Size",
"rotation": "Rotation",
"image": "Image",
"clear": "Clear",
"branding": "Branding",
"title_placeholder": "Title...",
"subtitle_placeholder": "Subtitle...",
"position": "Position",
"logo": "Logo",
"spin_logo": "Spin Logo",
"speed": "Speed",
"colors": "Colors",
"primary": "Primary",
"secondary": "Second",
"background": "Background",
"vignette": "Vignette",
"actions": "Actions",
"screenshot": "Screenshot",
"fullscreen": "Fullscreen",
"reset": "Reset",
"play_stop": "Play/Stop",
"hide": "Hide",
"export_theme": "Export",
"import_theme": "Import"
}

57
messages/et.json Normal file
View File

@@ -0,0 +1,57 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"audio_source": "Heliallikas",
"stop": "Peata",
"start": "Alusta",
"mic": "Mikrofon",
"tab": "Vahekaart",
"file": "Fail",
"tab_hint": "Vajuta Alusta, vali muusikat mängiv vahekaart ja märgi",
"tab_hint_bold": "Jaga vahekaardi heli",
"drop_audio": "Lohista helifail siia",
"file_loaded": "Fail laaditud. Vajuta Alusta esituse alustamiseks.",
"themes": "Teemad",
"theme_name_placeholder": "Teema nimi...",
"save": "Salvesta",
"visualization": "Visualiseerimine",
"circular": "Ring",
"bars": "Ribad",
"wave": "Laine",
"blob": "Plekk",
"bars_count": "Ribad",
"reactivity": "Reaktiivsus",
"sensitivity": "Tundlikkus",
"smoothing": "Silumine",
"bass_emphasis": "Bassi rõhk",
"effects": "Efektid",
"shake": "Värin",
"zoom": "Suum",
"glow": "Helendus",
"particles": "Osakesed",
"enable_particles": "Luba osakesed",
"density": "Tihedus",
"size": "Suurus",
"rotation": "Pöörlemine",
"image": "Pilt",
"clear": "Tühjenda",
"branding": "Kujundus",
"title_placeholder": "Pealkiri...",
"subtitle_placeholder": "Alapealkiri...",
"position": "Asukoht",
"logo": "Logo",
"spin_logo": "Pöörlev logo",
"speed": "Kiirus",
"colors": "Värvid",
"primary": "Põhivärv",
"secondary": "Teisene",
"background": "Taust",
"vignette": "Vinjett",
"actions": "Toimingud",
"screenshot": "Ekraanipilt",
"fullscreen": "Täisekraan",
"reset": "Lähtesta",
"play_stop": "Esita/Peata",
"hide": "Peida",
"export_theme": "Ekspordi",
"import_theme": "Impordi"
}

3866
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,16 +1,28 @@
{ {
"name": "audio-visualizer", "name": "audio-visualizer",
"version": "1.0.0", "private": true,
"private": true, "version": "0.0.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite dev",
"build": "vite build", "build": "vite build",
"preview": "vite preview" "preview": "vite preview",
}, "prepare": "svelte-kit sync || echo ''",
"devDependencies": { "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"@sveltejs/vite-plugin-svelte": "^3.1.0", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
"svelte": "^4.2.12", },
"vite": "^5.2.0" "devDependencies": {
} "@inlang/paraglide-js": "^2.10.0",
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/kit": "^2.50.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/forms": "^0.5.11",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.18",
"svelte": "^5.49.2",
"svelte-check": "^4.3.6",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"vite": "^7.3.1"
}
} }

View File

@@ -0,0 +1,15 @@
{
"$schema": "https://inlang.com/schema/project-settings",
"modules": [
"https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js",
"https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@2/dist/index.js"
],
"plugin.inlang.messageFormat": {
"pathPattern": "./messages/{locale}.json"
},
"baseLocale": "en",
"locales": [
"en",
"et"
]
}

View File

@@ -1,609 +0,0 @@
<script>
import { onMount, onDestroy } from "svelte";
import AudioVisualizer from "./lib/AudioVisualizer.svelte";
import Controls from "./lib/Controls.svelte";
const STORAGE_KEY = "ncs-visualizer-settings";
const defaults = {
selectedPreset: "cyanPink",
logoUrl: "",
title: "Audio Visualizer",
subtitle: "Now Playing",
bgImage: "",
bgColor: "#0a0a0a",
customPrimary: "#00d4ff",
customSecondary: "#ff006e",
useCustomColors: false,
sidebarOpen: true,
vignette: true,
audioSource: "mic",
audioUrl: "",
vizMode: "circular",
barCount: 48,
sensitivity: 1.0,
smoothing: 0.82,
bassEmphasis: 0.25,
particlesEnabled: true,
particleDensity: 1.0,
shakeAmount: 1.0,
zoomIntensity: 1.0,
glowIntensity: 0.6,
logoSpin: false,
logoSpinSpeed: 5,
particleImage: "",
particleSize: 1.0,
particleRotation: 0,
titlePosition: "top",
};
function loadSettings() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? { ...defaults, ...JSON.parse(raw) } : { ...defaults };
} catch {
return { ...defaults };
}
}
const saved = loadSettings();
let selectedPreset = saved.selectedPreset;
let logoUrl = saved.logoUrl;
let title = saved.title;
let subtitle = saved.subtitle;
let bgImage = saved.bgImage;
let bgColor = saved.bgColor;
let customPrimary = saved.customPrimary;
let customSecondary = saved.customSecondary;
let useCustomColors = saved.useCustomColors;
let isListening = false;
let sidebarOpen = saved.sidebarOpen;
let vignette = saved.vignette;
let audioSource = saved.audioSource;
let audioUrl = saved.audioUrl;
let vizMode = saved.vizMode;
let barCount = saved.barCount;
let sensitivity = saved.sensitivity;
let smoothing = saved.smoothing;
let bassEmphasis = saved.bassEmphasis;
let particlesEnabled = saved.particlesEnabled;
let particleDensity = saved.particleDensity;
let shakeAmount = saved.shakeAmount;
let zoomIntensity = saved.zoomIntensity;
let glowIntensity = saved.glowIntensity;
let logoSpin = saved.logoSpin;
let logoSpinSpeed = saved.logoSpinSpeed;
let particleImage = saved.particleImage;
let particleSize = saved.particleSize;
let particleRotation = saved.particleRotation;
let titlePosition = saved.titlePosition;
let visualizerComponent;
let toggleHidden = false;
let hideTimer;
function startHideTimer() {
clearTimeout(hideTimer);
if (!sidebarOpen) {
hideTimer = setTimeout(() => {
toggleHidden = true;
}, 2000);
}
}
function handleToggleClick() {
sidebarOpen = !sidebarOpen;
toggleHidden = false;
clearTimeout(hideTimer);
if (!sidebarOpen) startHideTimer();
}
$: if (!sidebarOpen) {
startHideTimer();
} else {
toggleHidden = false;
clearTimeout(hideTimer);
}
$: {
const settings = {
selectedPreset,
logoUrl,
title,
subtitle,
bgImage,
bgColor,
customPrimary,
customSecondary,
useCustomColors,
sidebarOpen,
vignette,
audioSource,
audioUrl,
vizMode,
barCount,
sensitivity,
smoothing,
bassEmphasis,
particlesEnabled,
particleDensity,
shakeAmount,
zoomIntensity,
glowIntensity,
logoSpin,
logoSpinSpeed,
particleImage,
particleSize,
particleRotation,
titlePosition,
};
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
} catch {}
}
function toggleListening() {
if (isListening) {
visualizerComponent.stopListening();
} else {
visualizerComponent.startListening();
}
}
function handleScreenshot() {
const dataUrl = visualizerComponent.takeScreenshot();
if (!dataUrl) return;
const link = document.createElement("a");
link.download = "visualizer-screenshot.png";
link.href = dataUrl;
link.click();
}
function handleFullscreen() {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().catch(() => {});
} else {
document.exitFullscreen().catch(() => {});
}
}
function handleReset() {
selectedPreset = defaults.selectedPreset;
logoUrl = defaults.logoUrl;
title = defaults.title;
subtitle = defaults.subtitle;
bgImage = defaults.bgImage;
bgColor = defaults.bgColor;
customPrimary = defaults.customPrimary;
customSecondary = defaults.customSecondary;
useCustomColors = defaults.useCustomColors;
vignette = defaults.vignette;
audioSource = defaults.audioSource;
audioUrl = defaults.audioUrl;
vizMode = defaults.vizMode;
barCount = defaults.barCount;
sensitivity = defaults.sensitivity;
smoothing = defaults.smoothing;
bassEmphasis = defaults.bassEmphasis;
particlesEnabled = defaults.particlesEnabled;
particleDensity = defaults.particleDensity;
shakeAmount = defaults.shakeAmount;
zoomIntensity = defaults.zoomIntensity;
glowIntensity = defaults.glowIntensity;
logoSpin = defaults.logoSpin;
logoSpinSpeed = defaults.logoSpinSpeed;
particleImage = defaults.particleImage;
particleSize = defaults.particleSize;
particleRotation = defaults.particleRotation;
titlePosition = defaults.titlePosition;
}
let fileCurrentTime = 0;
let fileDuration = 0;
let filePlaying = false;
let fileName = "";
function handleAudioFile(e) {
const file = e.detail;
if (file && visualizerComponent) {
if (isListening) {
visualizerComponent.stopListening();
}
audioSource = "file";
visualizerComponent.setAudioFile(file);
}
}
function handleFilePause() {
if (visualizerComponent) visualizerComponent.pauseAudio();
}
function handleFileResume() {
if (visualizerComponent) visualizerComponent.resumeAudio();
}
function handleFileSeek(e) {
if (visualizerComponent) visualizerComponent.seekAudio(e.detail);
}
function handleKeydown(e) {
// Ignore if user is typing in an input
if (
e.target.tagName === "INPUT" ||
e.target.tagName === "TEXTAREA" ||
e.target.isContentEditable
)
return;
switch (e.code) {
case "Space":
e.preventDefault();
toggleListening();
break;
case "KeyF":
e.preventDefault();
handleFullscreen();
break;
case "KeyH":
e.preventDefault();
handleToggleClick();
break;
}
}
onMount(() => {
window.addEventListener("keydown", handleKeydown);
});
onDestroy(() => {
window.removeEventListener("keydown", handleKeydown);
});
const colorPresets = {
cyanPink: {
name: "Cyan/Pink",
primary: "#00d4ff",
secondary: "#ff006e",
},
purpleOrange: {
name: "Purple/Orange",
primary: "#8b5cf6",
secondary: "#f97316",
},
greenYellow: {
name: "Green/Yellow",
primary: "#22c55e",
secondary: "#eab308",
},
pinkBlue: {
name: "Pink/Blue",
primary: "#ec4899",
secondary: "#3b82f6",
},
redOrange: {
name: "Red/Orange",
primary: "#ef4444",
secondary: "#f97316",
},
white: {
name: "White",
primary: "#ffffff",
secondary: "#888888",
},
};
$: currentColors = useCustomColors
? { primary: customPrimary, secondary: customSecondary }
: colorPresets[selectedPreset];
</script>
<main style="--bg-color: {bgColor}">
{#if bgImage}
<div class="bg-image" style="background-image: url({bgImage})"></div>
{/if}
{#if vignette}
<div class="vignette"></div>
{/if}
<div class="stage">
<AudioVisualizer
bind:this={visualizerComponent}
colors={currentColors}
{logoUrl}
{audioSource}
{vizMode}
{barCount}
{sensitivity}
{smoothing}
{bassEmphasis}
{particlesEnabled}
{particleDensity}
{shakeAmount}
{zoomIntensity}
{glowIntensity}
{logoSpin}
{logoSpinSpeed}
{particleImage}
{particleSize}
{particleRotation}
bind:isListening
bind:fileCurrentTime
bind:fileDuration
bind:filePlaying
bind:fileName
/>
</div>
<div class="titles title-{titlePosition}">
<h1 style="color: {currentColors.primary}">{title}</h1>
<h2 style="color: {currentColors.secondary}">{subtitle}</h2>
</div>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="toggle-zone"
class:hidden={toggleHidden}
on:mouseenter={() => {
toggleHidden = false;
}}
>
<button
class="sidebar-toggle"
class:open={sidebarOpen}
on:click={handleToggleClick}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
{#if sidebarOpen}
<path d="M15 18l-6-6 6-6" />
{:else}
<path d="M9 18l6-6-6-6" />
{/if}
</svg>
</button>
</div>
<aside class="sidebar" class:open={sidebarOpen}>
<Controls
bind:selectedPreset
bind:logoUrl
bind:title
bind:subtitle
bind:bgImage
bind:bgColor
bind:vignette
bind:audioSource
bind:audioUrl
bind:customPrimary
bind:customSecondary
bind:useCustomColors
bind:vizMode
bind:barCount
bind:sensitivity
bind:smoothing
bind:bassEmphasis
bind:particlesEnabled
bind:particleDensity
bind:shakeAmount
bind:zoomIntensity
bind:glowIntensity
bind:logoSpin
bind:logoSpinSpeed
bind:particleImage
bind:particleSize
bind:particleRotation
bind:titlePosition
{isListening}
{colorPresets}
onToggle={toggleListening}
on:screenshot={handleScreenshot}
on:fullscreen={handleFullscreen}
on:reset={handleReset}
on:audiofile={handleAudioFile}
on:filepause={handleFilePause}
on:fileresume={handleFileResume}
on:fileseek={handleFileSeek}
{fileCurrentTime}
{fileDuration}
{filePlaying}
{fileName}
/>
</aside>
</main>
<style>
main {
position: relative;
display: flex;
width: 100vw;
height: 100vh;
overflow: hidden;
background: var(--bg-color);
}
.bg-image {
position: absolute;
inset: 0;
background-size: cover;
background-position: center;
z-index: 0;
}
.vignette {
position: absolute;
inset: 0;
background: radial-gradient(
ellipse at center,
rgba(0, 0, 0, 0.5) 0%,
rgba(0, 0, 0, 1) 100%
);
z-index: 0;
}
.stage {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
}
.titles {
position: absolute;
z-index: 10;
pointer-events: none;
text-align: center;
padding: 1.5rem 2.5rem;
background: radial-gradient(
ellipse at center,
rgba(0, 0, 0, 0.7) 0%,
rgba(0, 0, 0, 0.4) 50%,
transparent 80%
);
text-shadow:
0 2px 8px rgba(0, 0, 0, 1),
0 0 30px rgba(0, 0, 0, 0.9);
}
.title-top {
top: 0;
left: 50%;
transform: translateX(-50%);
}
.title-bottom {
bottom: 0;
left: 50%;
transform: translateX(-50%);
}
.title-center {
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.title-top-left {
top: 0;
left: 0;
text-align: left;
}
.title-top-right {
top: 0;
right: 0;
text-align: right;
}
.title-bottom-left {
bottom: 0;
left: 0;
text-align: left;
}
.title-bottom-right {
bottom: 0;
right: 0;
text-align: right;
}
.title-left {
top: 50%;
left: 0;
transform: translateY(-50%);
text-align: left;
}
.title-right {
top: 50%;
right: 0;
transform: translateY(-50%);
text-align: right;
}
h1 {
font-size: clamp(1.4rem, 3.5vw, 3rem);
font-weight: 300;
letter-spacing: 0.2em;
text-transform: uppercase;
margin: 0;
}
h2 {
font-size: clamp(1rem, 2vw, 1.8rem);
font-weight: 300;
letter-spacing: 0.15em;
margin: 0.3rem 0 0 0;
opacity: 0.7;
}
.toggle-zone {
position: fixed;
top: 0;
right: 0;
width: 50px;
height: 100vh;
z-index: 30;
opacity: 1;
transition: opacity 0.5s ease;
}
.toggle-zone.hidden {
opacity: 0;
}
.toggle-zone.hidden:hover {
opacity: 1;
}
.sidebar-toggle {
position: absolute;
top: 50%;
right: 0;
transform: translateY(-50%);
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.15);
border-right: none;
border-radius: 8px 0 0 8px;
color: #fff;
padding: 0.75rem 0.4rem;
cursor: pointer;
transition: right 0.3s ease;
}
.sidebar-toggle.open {
right: 320px;
}
.sidebar-toggle:hover {
background: rgba(255, 255, 255, 0.15);
}
.sidebar {
position: fixed;
top: 0;
right: -320px;
width: 320px;
height: 100vh;
background: rgba(10, 10, 15, 0.92);
backdrop-filter: blur(12px);
border-left: 1px solid rgba(255, 255, 255, 0.1);
z-index: 20;
overflow-y: auto;
transition: right 0.3s ease;
padding: 1.5rem;
box-sizing: border-box;
}
.sidebar.open {
right: 0;
}
</style>

13
src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

18
src/app.html Normal file
View File

@@ -0,0 +1,18 @@
<!doctype html>
<html lang="%paraglide.lang%">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1"
/>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

12
src/hooks.server.ts Normal file
View File

@@ -0,0 +1,12 @@
import type { Handle } from '@sveltejs/kit';
import { paraglideMiddleware } from '$lib/paraglide/server';
const handleParaglide: Handle = ({ event, resolve }) => paraglideMiddleware(event.request, ({ request, locale }) => {
event.request = request;
return resolve(event, {
transformPageChunk: ({ html }) => html.replace('%paraglide.lang%', locale)
});
});
export const handle: Handle = handleParaglide;

3
src/hooks.ts Normal file
View File

@@ -0,0 +1,3 @@
import { deLocalizeUrl } from '$lib/paraglide/runtime';
export const reroute = (request) => deLocalizeUrl(request.url).pathname;

View File

@@ -1,27 +1,31 @@
<script> <script>
import { onMount, onDestroy } from "svelte"; import { onMount, onDestroy } from "svelte";
export let colors = { primary: "#00d4ff", secondary: "#ff006e" }; let {
export let logoUrl = ""; colors = { primary: "#00d4ff", secondary: "#ff006e" },
export let isListening = false; logoUrl = "",
export let audioSource = "mic"; // "mic", "tab", "link", "file" isListening = $bindable(false),
audioSource = "mic",
// Visualization mode: "circular", "bars", "wave", "blob" vizMode = "circular",
export let vizMode = "circular"; barCount = 48,
export let barCount = 48; sensitivity = 1.0,
export let sensitivity = 1.0; smoothing = 0.82,
export let smoothing = 0.82; bassEmphasis = 0.25,
export let bassEmphasis = 0.25; particlesEnabled = true,
export let particlesEnabled = true; particleDensity = 1.0,
export let particleDensity = 1.0; shakeAmount = 1.0,
export let shakeAmount = 1.0; zoomIntensity = 1.0,
export let zoomIntensity = 1.0; glowIntensity = 0.6,
export let glowIntensity = 0.6; logoSpin = false,
export let logoSpin = false; logoSpinSpeed = 5,
export let logoSpinSpeed = 5; particleImage = "",
export let particleImage = ""; particleSize = 1.0,
export let particleSize = 1.0; particleRotation = 0,
export let particleRotation = 0; fileCurrentTime = $bindable(0),
fileDuration = $bindable(0),
filePlaying = $bindable(false),
fileName = $bindable(""),
} = $props();
const DEFAULT_SIZE = 250; const DEFAULT_SIZE = 250;
const LINE_LIFT = 8; const LINE_LIFT = 8;
@@ -45,10 +49,10 @@
let dataArray = new Uint8Array(128); let dataArray = new Uint8Array(128);
let bufferLength = 128; let bufferLength = 128;
let size = DEFAULT_SIZE; let size = $state(DEFAULT_SIZE);
let centerX = DEFAULT_SIZE / 2; let centerX = DEFAULT_SIZE / 2;
let centerY = DEFAULT_SIZE / 2; let centerY = DEFAULT_SIZE / 2;
let baseRadius = DEFAULT_SIZE * 0.25; let baseRadius = $state(DEFAULT_SIZE * 0.25);
let cosValues = new Float32Array(barCount); let cosValues = new Float32Array(barCount);
let sinValues = new Float32Array(barCount); let sinValues = new Float32Array(barCount);
@@ -59,26 +63,47 @@
let blobPhase = 0; let blobPhase = 0;
// Particle image cache // Particle image cache
let pImg = null; let pImg = $state(null);
$: if (particleImage) { $effect(() => {
const img = new Image(); if (particleImage) {
img.src = particleImage; const img = new Image();
pImg = img; img.src = particleImage;
} else { pImg = img;
pImg = null; } else {
} pImg = null;
}
});
// Particle system // Particle system
let particles = []; let particles = [];
$: { // Recalculate angles when barCount changes
let prevBarCount = barCount;
$effect(() => {
const totalBars = barCount; const totalBars = barCount;
const halfBars = Math.ceil(totalBars / 2); const halfBars = Math.ceil(totalBars / 2);
cosValues = new Float32Array(totalBars); cosValues = new Float32Array(totalBars);
sinValues = new Float32Array(totalBars); sinValues = new Float32Array(totalBars);
smoothedValues = new Float32Array(halfBars); smoothedValues = new Float32Array(halfBars);
precalculateAngles(); precalculateAngles();
} prevBarCount = barCount;
});
// Update analyser smoothing
$effect(() => {
if (analyser) {
analyser.smoothingTimeConstant = smoothing;
}
});
// Clear particles when viz mode changes
let prevVizMode = vizMode;
$effect(() => {
if (vizMode !== prevVizMode) {
particles = [];
prevVizMode = vizMode;
}
});
function spawnParticles(loudness) { function spawnParticles(loudness) {
if (!particlesEnabled) return; if (!particlesEnabled) return;
@@ -91,7 +116,6 @@
let p; let p;
switch (vizMode) { switch (vizMode) {
case "bars": { case "bars": {
// Sparks rise from random bar tops
const halfBars = Math.ceil(barCount / 2); const halfBars = Math.ceil(barCount / 2);
const cw = canvas ? canvas.width : window.innerWidth; const cw = canvas ? canvas.width : window.innerWidth;
const ch = canvas ? canvas.height : window.innerHeight; const ch = canvas ? canvas.height : window.innerHeight;
@@ -118,7 +142,6 @@
break; break;
} }
case "wave": { case "wave": {
// Particles float upward from the wave line
const halfBars = Math.ceil(barCount / 2); const halfBars = Math.ceil(barCount / 2);
const cw = canvas ? canvas.width : window.innerWidth; const cw = canvas ? canvas.width : window.innerWidth;
const ch = canvas ? canvas.height : window.innerHeight; const ch = canvas ? canvas.height : window.innerHeight;
@@ -142,7 +165,6 @@
break; break;
} }
case "blob": { case "blob": {
// Particles swirl around the blob surface
const angle = Math.random() * Math.PI * 2; const angle = Math.random() * Math.PI * 2;
const halfBars = Math.ceil(barCount / 2); const halfBars = Math.ceil(barCount / 2);
const freqIdx = const freqIdx =
@@ -166,7 +188,6 @@
} }
case "circular": case "circular":
default: { default: {
// Radiate outward from circle
const angle = Math.random() * Math.PI * 2; const angle = Math.random() * Math.PI * 2;
const spawnR = baseRadius * 0.7 + Math.random() * baseRadius * 0.3; const spawnR = baseRadius * 0.7 + Math.random() * baseRadius * 0.3;
const speed = 1.5 + Math.random() * 5 * loudness; const speed = 1.5 + Math.random() * 5 * loudness;
@@ -195,9 +216,7 @@
const p = particles[i]; const p = particles[i];
p.x += p.vx; p.x += p.vx;
p.y += p.vy; p.y += p.vy;
// Gravity for bars mode (sparks fall slightly)
if (vizMode === "bars") p.vy += 0.04; if (vizMode === "bars") p.vy += 0.04;
// Slight drag for blob mode (swirl slows)
if (vizMode === "blob") { if (vizMode === "blob") {
p.vx *= 0.995; p.vx *= 0.995;
p.vy *= 0.995; p.vy *= 0.995;
@@ -237,16 +256,12 @@
function precalculateAngles() { function precalculateAngles() {
const totalBars = barCount; const totalBars = barCount;
const halfBars = Math.ceil(totalBars / 2); const halfBars = Math.ceil(totalBars / 2);
// Mirrored halves: right side goes bottom→top, left side mirrors
const angleStep = Math.PI / halfBars; const angleStep = Math.PI / halfBars;
const halfStep = angleStep / 2; const halfStep = angleStep / 2;
for (let i = 0; i < halfBars; i++) { for (let i = 0; i < halfBars; i++) {
// Right half: top → bottom
const rightAngle = -Math.PI / 2 + halfStep + i * angleStep; const rightAngle = -Math.PI / 2 + halfStep + i * angleStep;
cosValues[i] = Math.cos(rightAngle); cosValues[i] = Math.cos(rightAngle);
sinValues[i] = Math.sin(rightAngle); sinValues[i] = Math.sin(rightAngle);
// Left half: top → bottom (mirrored)
if (halfBars + i < totalBars) { if (halfBars + i < totalBars) {
const leftAngle = -Math.PI / 2 - halfStep - i * angleStep; const leftAngle = -Math.PI / 2 - halfStep - i * angleStep;
cosValues[halfBars + i] = Math.cos(leftAngle); cosValues[halfBars + i] = Math.cos(leftAngle);
@@ -257,12 +272,9 @@
async function initAudio() { async function initAudio() {
audioContext = new (window.AudioContext || window.webkitAudioContext)(); audioContext = new (window.AudioContext || window.webkitAudioContext)();
// Browsers may suspend AudioContext until a user gesture resumes it
if (audioContext.state === "suspended") { if (audioContext.state === "suspended") {
await audioContext.resume(); await audioContext.resume();
} }
analyser = audioContext.createAnalyser(); analyser = audioContext.createAnalyser();
analyser.fftSize = 8192; analyser.fftSize = 8192;
analyser.smoothingTimeConstant = smoothing; analyser.smoothingTimeConstant = smoothing;
@@ -272,7 +284,6 @@
let stream; let stream;
if (audioSource === "file" && audioElement) { if (audioSource === "file" && audioElement) {
// Direct Web Audio pipeline — best quality, no mic needed
audioSourceNode = audioContext.createMediaElementSource(audioElement); audioSourceNode = audioContext.createMediaElementSource(audioElement);
audioSourceNode.connect(analyser); audioSourceNode.connect(analyser);
analyser.connect(audioContext.destination); analyser.connect(audioContext.destination);
@@ -280,7 +291,6 @@
isListening = true; isListening = true;
return; return;
} else if (audioSource === "tab") { } else if (audioSource === "tab") {
// Tab audio capture via getDisplayMedia (Chrome/Edge only)
stream = await navigator.mediaDevices.getDisplayMedia({ stream = await navigator.mediaDevices.getDisplayMedia({
video: { width: 1, height: 1, frameRate: 1 }, video: { width: 1, height: 1, frameRate: 1 },
audio: { audio: {
@@ -290,8 +300,6 @@
systemAudio: "include", systemAudio: "include",
}, },
}); });
// Verify audio tracks exist (user must check "Share tab audio")
const audioTracks = stream.getAudioTracks(); const audioTracks = stream.getAudioTracks();
if (audioTracks.length === 0) { if (audioTracks.length === 0) {
stream.getTracks().forEach((t) => t.stop()); stream.getTracks().forEach((t) => t.stop());
@@ -299,19 +307,12 @@
'No audio captured. Make sure to check "Share tab audio" in the sharing dialog.', 'No audio captured. Make sure to check "Share tab audio" in the sharing dialog.',
); );
} }
// Stop the video track immediately — we only need audio
stream.getVideoTracks().forEach((t) => t.stop()); stream.getVideoTracks().forEach((t) => t.stop());
// Handle stream ending (user clicks "Stop sharing")
stream.addEventListener("inactive", () => stopListening()); stream.addEventListener("inactive", () => stopListening());
audioTracks.forEach((track) => audioTracks.forEach((track) =>
track.addEventListener("ended", () => stopListening()), track.addEventListener("ended", () => stopListening()),
); );
} else { } else {
// "mic" and "link" both use microphone capture
// For "link", the user plays music in an embedded/external player
// and the mic picks up the audio from speakers
stream = await navigator.mediaDevices.getUserMedia({ stream = await navigator.mediaDevices.getUserMedia({
audio: { audio: {
echoCancellation: false, echoCancellation: false,
@@ -327,15 +328,10 @@
isListening = true; isListening = true;
} }
$: if (analyser) {
analyser.smoothingTimeConstant = smoothing;
}
function getProcessedData() { function getProcessedData() {
const totalBars = barCount; const totalBars = barCount;
const halfBars = Math.ceil(totalBars / 2); const halfBars = Math.ceil(totalBars / 2);
const usableBins = (bufferLength * FREQ_RANGE) | 0; const usableBins = (bufferLength * FREQ_RANGE) | 0;
let sum = 0; let sum = 0;
for (let i = 0; i < halfBars; i++) { for (let i = 0; i < halfBars; i++) {
const t = i / (halfBars - 1 || 1); const t = i / (halfBars - 1 || 1);
@@ -347,16 +343,12 @@
(dataArray[binIdx] || 0) + (dataArray[binIdx] || 0) +
(dataArray[hi] || 0)) / (dataArray[hi] || 0)) /
3; 3;
// Normalize and apply sensitivity
const normalized = Math.min(1, (raw * sensitivity) / 255); const normalized = Math.min(1, (raw * sensitivity) / 255);
// Gentle curve to keep activity spread evenly
const curved = Math.pow(normalized, 8); const curved = Math.pow(normalized, 8);
// Optional bass emphasis as additive boost for lower bars
const bassBoost = (1 - t) * bassEmphasis * normalized * 0.3; const bassBoost = (1 - t) * bassEmphasis * normalized * 0.3;
const spiked = Math.min(1, curved + bassBoost) * 255; const spiked = Math.min(1, curved + bassBoost) * 255;
smoothedValues[i] += (spiked - smoothedValues[i]) * SMOOTH_FACTOR; smoothedValues[i] += (spiked - smoothedValues[i]) * SMOOTH_FACTOR;
} }
// Spatial smoothing: blend adjacent bars for a more unified shape
for (let pass = 0; pass < 2; pass++) { for (let pass = 0; pass < 2; pass++) {
for (let i = 1; i < halfBars - 1; i++) { for (let i = 1; i < halfBars - 1; i++) {
smoothedValues[i] = smoothedValues[i] =
@@ -366,27 +358,17 @@
} }
} }
for (let i = 0; i < halfBars; i++) sum += smoothedValues[i]; for (let i = 0; i < halfBars; i++) sum += smoothedValues[i];
const avgLoudness = (sum / halfBars) * INV_255; const avgLoudness = (sum / halfBars) * INV_255;
smoothedLoudness += (avgLoudness - smoothedLoudness) * 0.2; smoothedLoudness += (avgLoudness - smoothedLoudness) * 0.2;
return { halfBars, totalBars, sum }; return { halfBars, totalBars, sum };
} }
// Clear particles when viz mode changes to avoid stale coordinates
let prevVizMode = vizMode;
$: if (vizMode !== prevVizMode) {
particles = [];
prevVizMode = vizMode;
}
function applyWrapperEffects() { function applyWrapperEffects() {
const shakeIntensity = 12 * shakeAmount; const shakeIntensity = 12 * shakeAmount;
const zoom = isFinite(zoomIntensity) ? zoomIntensity : 1.0; const zoom = isFinite(zoomIntensity) ? zoomIntensity : 1.0;
const targetScale = const targetScale =
MIN_SCALE + smoothedLoudness * (MAX_SCALE - MIN_SCALE) * zoom; MIN_SCALE + smoothedLoudness * (MAX_SCALE - MIN_SCALE) * zoom;
smoothedScale += (targetScale - smoothedScale) * SCALE_SMOOTH; smoothedScale += (targetScale - smoothedScale) * SCALE_SMOOTH;
const shakeX = (Math.random() - 0.5) * shakeIntensity * smoothedLoudness; const shakeX = (Math.random() - 0.5) * shakeIntensity * smoothedLoudness;
const shakeY = (Math.random() - 0.5) * shakeIntensity * smoothedLoudness; const shakeY = (Math.random() - 0.5) * shakeIntensity * smoothedLoudness;
const bloom = 1 + smoothedLoudness * glowIntensity; const bloom = 1 + smoothedLoudness * glowIntensity;
@@ -404,14 +386,11 @@
function drawCircular(halfBars, totalBars) { function drawCircular(halfBars, totalBars) {
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++) {
const dataIdx = i < halfBars ? i : i - halfBars; const dataIdx = i < halfBars ? i : i - halfBars;
barHeights[i] = barHeights[i] =
baseRadius + 5 + smoothedValues[dataIdx] * maxBarHeight * INV_255; baseRadius + 5 + smoothedValues[dataIdx] * maxBarHeight * INV_255;
} }
// Build outer points for mirrored halves
const outerPoints = []; const outerPoints = [];
for (let i = 0; i < halfBars; i++) { for (let i = 0; i < halfBars; i++) {
const r = barHeights[i] + LINE_LIFT; const r = barHeights[i] + LINE_LIFT;
@@ -427,8 +406,6 @@
y: centerY + sinValues[i] * r, y: centerY + sinValues[i] * r,
}); });
} }
// Draw filled area from circle edge to outer line
const len = outerPoints.length; const len = outerPoints.length;
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(outerPoints[0].x, outerPoints[0].y); ctx.moveTo(outerPoints[0].x, outerPoints[0].y);
@@ -440,12 +417,8 @@
ctx.quadraticCurveTo(curr.x, curr.y, mx, my); ctx.quadraticCurveTo(curr.x, curr.y, mx, my);
} }
ctx.closePath(); ctx.closePath();
// Cut out the inner circle
ctx.moveTo(centerX + baseRadius, centerY); ctx.moveTo(centerX + baseRadius, centerY);
ctx.arc(centerX, centerY, baseRadius, 0, Math.PI * 2, true); ctx.arc(centerX, centerY, baseRadius, 0, Math.PI * 2, true);
// Gradient fill
const grad = ctx.createRadialGradient( const grad = ctx.createRadialGradient(
centerX, centerX,
centerY, centerY,
@@ -471,7 +444,6 @@
const maxBarHeight = canvas.height * 0.7; const maxBarHeight = canvas.height * 0.7;
const startX = 0; const startX = 0;
const bottomY = canvas.height; const bottomY = canvas.height;
const grad = ctx.createLinearGradient( const grad = ctx.createLinearGradient(
0, 0,
bottomY - maxBarHeight, bottomY - maxBarHeight,
@@ -481,13 +453,11 @@
grad.addColorStop(0, colors.primary); grad.addColorStop(0, colors.primary);
grad.addColorStop(1, colors.secondary); grad.addColorStop(1, colors.secondary);
ctx.fillStyle = grad; ctx.fillStyle = grad;
for (let i = 0; i < totalBarsToShow; i++) { for (let i = 0; i < totalBarsToShow; i++) {
const val = smoothedValues[i] * INV_255; const val = smoothedValues[i] * INV_255;
const h = Math.max(2, val * maxBarHeight); const h = Math.max(2, val * maxBarHeight);
const x = startX + i * (barWidth + barGap); const x = startX + i * (barWidth + barGap);
const radius = Math.min(barWidth / 2, 4); const radius = Math.min(barWidth / 2, 4);
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(x + radius, bottomY - h); ctx.moveTo(x + radius, bottomY - h);
ctx.lineTo(x + barWidth - radius, bottomY - h); ctx.lineTo(x + barWidth - radius, bottomY - h);
@@ -511,8 +481,6 @@
const maxAmp = canvas.height * 0.3; const maxAmp = canvas.height * 0.3;
const startX = 0; const startX = 0;
const midY = canvas.height * 0.5; const midY = canvas.height * 0.5;
// Primary wave
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(startX, midY); ctx.moveTo(startX, midY);
for (let i = 0; i < halfBars; i++) { for (let i = 0; i < halfBars; i++) {
@@ -533,18 +501,14 @@
); );
} }
} }
// Close to form filled area
ctx.lineTo(startX + totalWidth, midY); ctx.lineTo(startX + totalWidth, midY);
ctx.lineTo(startX, midY); ctx.lineTo(startX, midY);
ctx.closePath(); ctx.closePath();
const grad = ctx.createLinearGradient(0, midY - maxAmp, 0, midY); const grad = ctx.createLinearGradient(0, midY - maxAmp, 0, midY);
grad.addColorStop(0, colors.primary + "cc"); grad.addColorStop(0, colors.primary + "cc");
grad.addColorStop(1, colors.primary + "11"); grad.addColorStop(1, colors.primary + "11");
ctx.fillStyle = grad; ctx.fillStyle = grad;
ctx.fill(); ctx.fill();
// Stroke the wave line
ctx.beginPath(); ctx.beginPath();
for (let i = 0; i < halfBars; i++) { for (let i = 0; i < halfBars; i++) {
const t = i / (halfBars - 1); const t = i / (halfBars - 1);
@@ -560,8 +524,6 @@
ctx.shadowBlur = 10 * glowIntensity; ctx.shadowBlur = 10 * glowIntensity;
ctx.stroke(); ctx.stroke();
ctx.shadowBlur = 0; ctx.shadowBlur = 0;
// Mirror wave (secondary color, below)
ctx.beginPath(); ctx.beginPath();
for (let i = 0; i < halfBars; i++) { for (let i = 0; i < halfBars; i++) {
const t = i / (halfBars - 1); const t = i / (halfBars - 1);
@@ -574,13 +536,11 @@
ctx.lineTo(startX + totalWidth, midY); ctx.lineTo(startX + totalWidth, midY);
ctx.lineTo(startX, midY); ctx.lineTo(startX, midY);
ctx.closePath(); ctx.closePath();
const grad2 = ctx.createLinearGradient(0, midY, 0, midY + maxAmp * 0.6); const grad2 = ctx.createLinearGradient(0, midY, 0, midY + maxAmp * 0.6);
grad2.addColorStop(0, colors.secondary + "11"); grad2.addColorStop(0, colors.secondary + "11");
grad2.addColorStop(1, colors.secondary + "88"); grad2.addColorStop(1, colors.secondary + "88");
ctx.fillStyle = grad2; ctx.fillStyle = grad2;
ctx.fill(); ctx.fill();
ctx.beginPath(); ctx.beginPath();
for (let i = 0; i < halfBars; i++) { for (let i = 0; i < halfBars; i++) {
const t = i / (halfBars - 1); const t = i / (halfBars - 1);
@@ -602,14 +562,11 @@
blobPhase += 0.008; blobPhase += 0.008;
const points = 64; const points = 64;
const maxDeform = size * 0.2; const maxDeform = size * 0.2;
ctx.beginPath(); ctx.beginPath();
for (let i = 0; i <= points; i++) { for (let i = 0; i <= points; i++) {
const angle = (i / points) * Math.PI * 2; const angle = (i / points) * Math.PI * 2;
// Map angle to frequency bin
const freqIdx = Math.floor((i / points) * halfBars) % halfBars; const freqIdx = Math.floor((i / points) * halfBars) % halfBars;
const val = smoothedValues[freqIdx] * INV_255; const val = smoothedValues[freqIdx] * INV_255;
// Perlin-like noise via layered sin
const noise = const noise =
Math.sin(angle * 3 + blobPhase) * 0.3 + Math.sin(angle * 3 + blobPhase) * 0.3 +
Math.sin(angle * 5 - blobPhase * 1.3) * 0.2 + Math.sin(angle * 5 - blobPhase * 1.3) * 0.2 +
@@ -621,7 +578,6 @@
else ctx.lineTo(x, y); else ctx.lineTo(x, y);
} }
ctx.closePath(); ctx.closePath();
const grad = ctx.createRadialGradient( const grad = ctx.createRadialGradient(
centerX, centerX,
centerY, centerY,
@@ -638,8 +594,6 @@
ctx.shadowBlur = 20 * glowIntensity; ctx.shadowBlur = 20 * glowIntensity;
ctx.fill(); ctx.fill();
ctx.shadowBlur = 0; ctx.shadowBlur = 0;
// Inner glow circle
ctx.beginPath(); ctx.beginPath();
ctx.arc(centerX, centerY, baseRadius * 0.95, 0, Math.PI * 2); ctx.arc(centerX, centerY, baseRadius * 0.95, 0, Math.PI * 2);
const innerGrad = ctx.createRadialGradient( const innerGrad = ctx.createRadialGradient(
@@ -658,7 +612,6 @@
function draw() { function draw() {
if (!ctx) return; if (!ctx) return;
const useFullViewport = vizMode === "bars" || vizMode === "wave"; const useFullViewport = vizMode === "bars" || vizMode === "wave";
const cw = useFullViewport const cw = useFullViewport
? window.innerWidth ? window.innerWidth
@@ -671,11 +624,9 @@
canvas.height = ch; canvas.height = ch;
} }
ctx.clearRect(0, 0, cw, ch); ctx.clearRect(0, 0, cw, ch);
if (analyser) { if (analyser) {
analyser.getByteFrequencyData(dataArray); analyser.getByteFrequencyData(dataArray);
} }
if (useFullViewport) { if (useFullViewport) {
centerX = cw / 2; centerX = cw / 2;
centerY = ch / 2; centerY = ch / 2;
@@ -684,13 +635,9 @@
centerX = offset + size / 2; centerX = offset + size / 2;
centerY = offset + size / 2; centerY = offset + size / 2;
} }
const { halfBars, totalBars } = getProcessedData(); const { halfBars, totalBars } = getProcessedData();
applyWrapperEffects(); applyWrapperEffects();
spawnParticles(smoothedLoudness); spawnParticles(smoothedLoudness);
// Draw based on selected mode
switch (vizMode) { switch (vizMode) {
case "bars": case "bars":
drawBars(halfBars); drawBars(halfBars);
@@ -706,10 +653,7 @@
drawCircular(halfBars, totalBars); drawCircular(halfBars, totalBars);
break; break;
} }
// Draw particles on top
updateAndDrawParticles(); updateAndDrawParticles();
animationId = requestAnimationFrame(draw); animationId = requestAnimationFrame(draw);
} }
@@ -725,7 +669,6 @@
} 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.message || "Failed to start audio. Check browser permissions.");
// Clean up partial state
if (audioContext) { if (audioContext) {
audioContext.close().catch(() => {}); audioContext.close().catch(() => {});
audioContext = null; audioContext = null;
@@ -754,11 +697,6 @@
draw(); draw();
} }
export let fileCurrentTime = 0;
export let fileDuration = 0;
export let filePlaying = false;
export let fileName = "";
export function setAudioFile(file) { export function setAudioFile(file) {
if (audioElement) { if (audioElement) {
audioElement.pause(); audioElement.pause();
@@ -772,7 +710,6 @@
filePlaying = false; filePlaying = false;
fileCurrentTime = 0; fileCurrentTime = 0;
fileDuration = 0; fileDuration = 0;
audioElement.addEventListener("loadedmetadata", () => { audioElement.addEventListener("loadedmetadata", () => {
fileDuration = audioElement.duration; fileDuration = audioElement.duration;
}); });
@@ -788,21 +725,15 @@
} }
export function pauseAudio() { export function pauseAudio() {
if (audioElement) { if (audioElement) audioElement.pause();
audioElement.pause();
}
} }
export function resumeAudio() { export function resumeAudio() {
if (audioElement) { if (audioElement) audioElement.play();
audioElement.play();
}
} }
export function seekAudio(time) { export function seekAudio(time) {
if (audioElement) { if (audioElement) audioElement.currentTime = time;
audioElement.currentTime = time;
}
} }
export function takeScreenshot() { export function takeScreenshot() {
@@ -852,7 +783,6 @@
justify-content: center; justify-content: center;
align-items: center; align-items: center;
} }
.visualizer-wrapper { .visualizer-wrapper {
position: relative; position: relative;
display: flex; display: flex;
@@ -862,11 +792,9 @@
will-change: transform; will-change: transform;
z-index: 5; z-index: 5;
} }
canvas { canvas {
display: block; display: block;
} }
.logo-container { .logo-container {
position: absolute; position: absolute;
border-radius: 50%; border-radius: 50%;
@@ -882,17 +810,14 @@
overflow: hidden; overflow: hidden;
z-index: 6; z-index: 6;
} }
.logo-container img { .logo-container img {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
} }
.logo-container.spinning { .logo-container.spinning {
animation: logo-spin var(--spin-duration, 4s) linear infinite; animation: logo-spin var(--spin-duration, 4s) linear infinite;
} }
@keyframes -global-logo-spin { @keyframes -global-logo-spin {
from { from {
transform: rotate(0deg); transform: rotate(0deg);

File diff suppressed because it is too large Load Diff

View File

Before

Width:  |  Height:  |  Size: 835 KiB

After

Width:  |  Height:  |  Size: 835 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

1
src/lib/index.ts Normal file
View File

@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

View File

@@ -1,7 +0,0 @@
import App from './App.svelte';
const app = new App({
target: document.getElementById('app')
});
export default app;

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import "./layout.css";
let { children } = $props();
</script>
{@render children()}

530
src/routes/+page.svelte Normal file
View File

@@ -0,0 +1,530 @@
<script>
import { onMount, onDestroy } from "svelte";
import { browser } from "$app/environment";
import AudioVisualizer from "$lib/AudioVisualizer.svelte";
import Controls from "$lib/Controls.svelte";
const STORAGE_KEY = "ncs-visualizer-settings";
const THEMES_KEY = "ncs-visualizer-themes";
const defaults = {
selectedPreset: "cyanPink",
logoUrl: "",
title: "Audio Visualizer",
subtitle: "Now Playing",
bgImage: "",
bgColor: "#0a0a0a",
customPrimary: "#00d4ff",
customSecondary: "#ff006e",
useCustomColors: false,
sidebarOpen: true,
vignette: true,
audioSource: "mic",
vizMode: "circular",
barCount: 48,
sensitivity: 1.0,
smoothing: 0.82,
bassEmphasis: 0.25,
particlesEnabled: true,
particleDensity: 1.0,
shakeAmount: 1.0,
zoomIntensity: 1.0,
glowIntensity: 0.6,
logoSpin: false,
logoSpinSpeed: 5,
particleImage: "",
particleSize: 1.0,
particleRotation: 0,
titlePosition: "top",
};
function loadSettings() {
if (!browser) return { ...defaults };
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? { ...defaults, ...JSON.parse(raw) } : { ...defaults };
} catch {
return { ...defaults };
}
}
function loadThemes() {
if (!browser) return [];
try {
const raw = localStorage.getItem(THEMES_KEY);
return raw ? JSON.parse(raw) : [];
} catch {
return [];
}
}
const saved = loadSettings();
let selectedPreset = $state(saved.selectedPreset);
let logoUrl = $state(saved.logoUrl);
let title = $state(saved.title);
let subtitle = $state(saved.subtitle);
let bgImage = $state(saved.bgImage);
let bgColor = $state(saved.bgColor);
let customPrimary = $state(saved.customPrimary);
let customSecondary = $state(saved.customSecondary);
let useCustomColors = $state(saved.useCustomColors);
let isListening = $state(false);
let sidebarOpen = $state(saved.sidebarOpen);
let vignette = $state(saved.vignette);
let audioSource = $state(saved.audioSource);
let vizMode = $state(saved.vizMode);
let barCount = $state(saved.barCount);
let sensitivity = $state(saved.sensitivity);
let smoothing = $state(saved.smoothing);
let bassEmphasis = $state(saved.bassEmphasis);
let particlesEnabled = $state(saved.particlesEnabled);
let particleDensity = $state(saved.particleDensity);
let shakeAmount = $state(saved.shakeAmount);
let zoomIntensity = $state(saved.zoomIntensity);
let glowIntensity = $state(saved.glowIntensity);
let logoSpin = $state(saved.logoSpin);
let logoSpinSpeed = $state(saved.logoSpinSpeed);
let particleImage = $state(saved.particleImage);
let particleSize = $state(saved.particleSize);
let particleRotation = $state(saved.particleRotation);
let titlePosition = $state(saved.titlePosition);
let savedThemes = $state(loadThemes());
let visualizerComponent = $state(null);
let toggleHidden = $state(false);
let hideTimer;
let fileCurrentTime = $state(0);
let fileDuration = $state(0);
let filePlaying = $state(false);
let fileName = $state("");
function startHideTimer() {
clearTimeout(hideTimer);
if (!sidebarOpen) {
hideTimer = setTimeout(() => {
toggleHidden = true;
}, 2000);
}
}
function handleToggleClick() {
sidebarOpen = !sidebarOpen;
toggleHidden = false;
clearTimeout(hideTimer);
if (!sidebarOpen) startHideTimer();
}
$effect(() => {
if (!sidebarOpen) {
startHideTimer();
} else {
toggleHidden = false;
clearTimeout(hideTimer);
}
});
// Persist settings
$effect(() => {
if (!browser) return;
const settings = {
selectedPreset,
logoUrl,
title,
subtitle,
bgImage,
bgColor,
customPrimary,
customSecondary,
useCustomColors,
sidebarOpen,
vignette,
audioSource,
vizMode,
barCount,
sensitivity,
smoothing,
bassEmphasis,
particlesEnabled,
particleDensity,
shakeAmount,
zoomIntensity,
glowIntensity,
logoSpin,
logoSpinSpeed,
particleImage,
particleSize,
particleRotation,
titlePosition,
};
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
} catch {}
});
function handleThemesChanged(themes) {
savedThemes = themes;
if (browser) {
try {
localStorage.setItem(THEMES_KEY, JSON.stringify(savedThemes));
} catch {}
}
}
function toggleListening() {
if (isListening) {
visualizerComponent.stopListening();
} else {
visualizerComponent.startListening();
}
}
function handleScreenshot() {
const dataUrl = visualizerComponent.takeScreenshot();
if (!dataUrl) return;
const link = document.createElement("a");
link.download = "visualizer-screenshot.png";
link.href = dataUrl;
link.click();
}
function handleFullscreen() {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().catch(() => {});
} else {
document.exitFullscreen().catch(() => {});
}
}
function handleReset() {
selectedPreset = defaults.selectedPreset;
logoUrl = defaults.logoUrl;
title = defaults.title;
subtitle = defaults.subtitle;
bgImage = defaults.bgImage;
bgColor = defaults.bgColor;
customPrimary = defaults.customPrimary;
customSecondary = defaults.customSecondary;
useCustomColors = defaults.useCustomColors;
vignette = defaults.vignette;
audioSource = defaults.audioSource;
vizMode = defaults.vizMode;
barCount = defaults.barCount;
sensitivity = defaults.sensitivity;
smoothing = defaults.smoothing;
bassEmphasis = defaults.bassEmphasis;
particlesEnabled = defaults.particlesEnabled;
particleDensity = defaults.particleDensity;
shakeAmount = defaults.shakeAmount;
zoomIntensity = defaults.zoomIntensity;
glowIntensity = defaults.glowIntensity;
logoSpin = defaults.logoSpin;
logoSpinSpeed = defaults.logoSpinSpeed;
particleImage = defaults.particleImage;
particleSize = defaults.particleSize;
particleRotation = defaults.particleRotation;
titlePosition = defaults.titlePosition;
}
function handleAudioFile(file) {
if (file && visualizerComponent) {
if (isListening) visualizerComponent.stopListening();
audioSource = "file";
visualizerComponent.setAudioFile(file);
}
}
function handleFilePause() {
if (visualizerComponent) visualizerComponent.pauseAudio();
}
function handleFileResume() {
if (visualizerComponent) visualizerComponent.resumeAudio();
}
function handleFileSeek(time) {
if (visualizerComponent) visualizerComponent.seekAudio(time);
}
function handleKeydown(e) {
if (
e.target.tagName === "INPUT" ||
e.target.tagName === "TEXTAREA" ||
e.target.isContentEditable
)
return;
switch (e.code) {
case "Space":
e.preventDefault();
toggleListening();
break;
case "KeyF":
e.preventDefault();
handleFullscreen();
break;
case "KeyH":
e.preventDefault();
handleToggleClick();
break;
}
}
onMount(() => {
window.addEventListener("keydown", handleKeydown);
});
onDestroy(() => {
window.removeEventListener("keydown", handleKeydown);
});
const colorPresets = {
cyanPink: {
name: "Cyan/Pink",
primary: "#00d4ff",
secondary: "#ff006e",
},
purpleOrange: {
name: "Purple/Orange",
primary: "#8b5cf6",
secondary: "#f97316",
},
greenYellow: {
name: "Green/Yellow",
primary: "#22c55e",
secondary: "#eab308",
},
pinkBlue: {
name: "Pink/Blue",
primary: "#ec4899",
secondary: "#3b82f6",
},
redOrange: {
name: "Red/Orange",
primary: "#ef4444",
secondary: "#f97316",
},
white: { name: "White", primary: "#ffffff", secondary: "#888888" },
};
let currentColors = $derived(
useCustomColors
? { primary: customPrimary, secondary: customSecondary }
: colorPresets[selectedPreset],
);
</script>
<svelte:head>
<title>{title}</title>
</svelte:head>
<main
class="relative flex w-screen h-screen overflow-hidden select-none"
style="background: {bgColor}"
>
{#if bgImage}
<div
class="absolute inset-0 bg-cover bg-center z-0"
style="background-image: url({bgImage})"
></div>
{/if}
{#if vignette}
<div
class="absolute inset-0 z-0"
style="background: radial-gradient(ellipse at center, rgba(0,0,0,0.5) 0%, rgba(0,0,0,1) 100%)"
></div>
{/if}
<div class="absolute inset-0 flex items-center justify-center z-[1]">
<AudioVisualizer
bind:this={visualizerComponent}
colors={currentColors}
{logoUrl}
{audioSource}
{vizMode}
{barCount}
{sensitivity}
{smoothing}
{bassEmphasis}
{particlesEnabled}
{particleDensity}
{shakeAmount}
{zoomIntensity}
{glowIntensity}
{logoSpin}
{logoSpinSpeed}
{particleImage}
{particleSize}
{particleRotation}
bind:isListening
bind:fileCurrentTime
bind:fileDuration
bind:filePlaying
bind:fileName
/>
</div>
<!-- Title overlay -->
<div class="title-overlay title-{titlePosition}">
<h1
class="text-[clamp(1.4rem,3.5vw,3rem)] font-light tracking-[0.2em] uppercase m-0"
style="color: {currentColors.primary}"
>
{title}
</h1>
<h2
class="text-[clamp(1rem,2vw,1.8rem)] font-light tracking-[0.15em] mt-1 opacity-70 m-0"
style="color: {currentColors.secondary}"
>
{subtitle}
</h2>
</div>
<!-- Sidebar toggle -->
<div
role="region"
aria-label="Sidebar toggle"
class="fixed top-0 right-0 w-[50px] h-screen z-30 transition-opacity duration-500 {toggleHidden
? 'opacity-0 hover:opacity-100'
: 'opacity-100'}"
onmouseenter={() => {
toggleHidden = false;
}}
>
<button
class="absolute top-1/2 -translate-y-1/2 bg-white/[0.08] border border-white/15 border-r-0 rounded-l-lg text-white py-3 px-1.5 cursor-pointer transition-all duration-300 hover:bg-white/15
{sidebarOpen ? 'right-[320px]' : 'right-0'}"
onclick={handleToggleClick}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
{#if sidebarOpen}
<path d="M15 18l-6-6 6-6" />
{:else}
<path d="M9 18l6-6-6-6" />
{/if}
</svg>
</button>
</div>
<!-- Sidebar -->
<aside
class="fixed top-0 w-[320px] h-screen bg-[rgba(10,10,15,0.92)] backdrop-blur-xl border-l border-white/10 z-20 overflow-y-auto transition-all duration-300 p-5 box-border
{sidebarOpen ? 'right-0' : '-right-[320px]'}"
>
<Controls
bind:selectedPreset
bind:logoUrl
bind:title
bind:subtitle
bind:bgImage
bind:bgColor
bind:vignette
bind:audioSource
bind:customPrimary
bind:customSecondary
bind:useCustomColors
bind:vizMode
bind:barCount
bind:sensitivity
bind:smoothing
bind:bassEmphasis
bind:particlesEnabled
bind:particleDensity
bind:shakeAmount
bind:zoomIntensity
bind:glowIntensity
bind:logoSpin
bind:logoSpinSpeed
bind:particleImage
bind:particleSize
bind:particleRotation
bind:titlePosition
bind:savedThemes
{isListening}
{colorPresets}
onToggle={toggleListening}
onscreenshot={handleScreenshot}
onfullscreen={handleFullscreen}
onreset={handleReset}
onaudiofile={handleAudioFile}
onfilepause={handleFilePause}
onfileresume={handleFileResume}
onfileseek={handleFileSeek}
onthemeschanged={handleThemesChanged}
{fileCurrentTime}
{fileDuration}
{filePlaying}
{fileName}
/>
</aside>
</main>
<style>
.title-overlay {
position: absolute;
z-index: 10;
pointer-events: none;
text-align: center;
padding: 1.5rem 2.5rem;
background: radial-gradient(
ellipse at center,
rgba(0, 0, 0, 0.7) 0%,
rgba(0, 0, 0, 0.4) 50%,
transparent 80%
);
text-shadow:
0 2px 8px rgba(0, 0, 0, 1),
0 0 30px rgba(0, 0, 0, 0.9);
}
.title-top {
top: 0;
left: 50%;
transform: translateX(-50%);
}
.title-bottom {
bottom: 0;
left: 50%;
transform: translateX(-50%);
}
.title-center {
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.title-top-left {
top: 0;
left: 0;
text-align: left;
}
.title-top-right {
top: 0;
right: 0;
text-align: right;
}
.title-bottom-left {
bottom: 0;
left: 0;
text-align: left;
}
.title-bottom-right {
bottom: 0;
right: 0;
text-align: right;
}
.title-left {
top: 50%;
left: 0;
transform: translateY(-50%);
text-align: left;
}
.title-right {
top: 50%;
right: 0;
transform: translateY(-50%);
text-align: right;
}
</style>

2
src/routes/+page.ts Normal file
View File

@@ -0,0 +1,2 @@
export const ssr = false;
export const prerender = false;

3
src/routes/layout.css Normal file
View File

@@ -0,0 +1,3 @@
@import 'tailwindcss';
@plugin '@tailwindcss/forms';
@plugin '@tailwindcss/typography';

3
static/robots.txt Normal file
View File

@@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow:

13
svelte.config.js Normal file
View File

@@ -0,0 +1,13 @@
import adapter from '@sveltejs/adapter-auto';
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
adapter: adapter()
}
};
export default config;

20
tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"rewriteRelativeImportExtensions": true,
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// To make changes to top-level options such as include and exclude, we recommend extending
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
}

View File

@@ -1,6 +0,0 @@
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
export default defineConfig({
plugins: [svelte()]
});

12
vite.config.ts Normal file
View File

@@ -0,0 +1,12 @@
import { paraglideVitePlugin } from '@inlang/paraglide-js';
import tailwindcss from '@tailwindcss/vite';
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [
tailwindcss(),
sveltekit(),
paraglideVitePlugin({ project: './project.inlang', outdir: './src/lib/paraglide' })
]
});