Files
audio-visualizer/src/App.svelte

603 lines
13 KiB
Svelte

<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,
autoSubtitle: false,
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 autoSubtitle = saved.autoSubtitle;
let titlePosition = saved.titlePosition;
let capturedTrackLabel = "";
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);
}
$: if (autoSubtitle && capturedTrackLabel) {
subtitle = capturedTrackLabel;
}
$: {
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,
autoSubtitle,
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;
autoSubtitle = defaults.autoSubtitle;
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}
bind:capturedTrackLabel
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:autoSubtitle
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>