603 lines
13 KiB
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>
|