Intensify visualizer and add fullscreen control
This commit is contained in:
parent
6b6fb26a6e
commit
4fe19f5abd
|
|
@ -4,7 +4,7 @@
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Blastoise! A very special music server</title>
|
<title>Blastoise! A very special music server</title>
|
||||||
<link rel="stylesheet" href="/styles.css?v=21">
|
<link rel="stylesheet" href="/styles.css?v=22">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
|
|
@ -171,6 +171,7 @@
|
||||||
|
|
||||||
<div id="visualizer-shell" class="visualizer-shell hidden">
|
<div id="visualizer-shell" class="visualizer-shell hidden">
|
||||||
<canvas id="visualizer-canvas" aria-hidden="true"></canvas>
|
<canvas id="visualizer-canvas" aria-hidden="true"></canvas>
|
||||||
|
<button id="btn-visualizer-fullscreen" class="visualizer-fullscreen-btn" title="Fullscreen visualizer" aria-label="Fullscreen visualizer">⛶</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="player-bar">
|
<div id="player-bar">
|
||||||
|
|
|
||||||
|
|
@ -196,8 +196,24 @@ h3 { font-size: 0.8rem; color: #999; margin-bottom: 0.3rem; text-transform: uppe
|
||||||
.context-menu-item.danger:hover { background: #3a2a2a; }
|
.context-menu-item.danger:hover { background: #3a2a2a; }
|
||||||
|
|
||||||
/* Player bar */
|
/* Player bar */
|
||||||
.visualizer-shell { --visualizer-bg: rgba(10, 14, 12, 0.9); --visualizer-accent: #44ee88; --visualizer-secondary: #66aaff; --visualizer-muted: rgba(255, 255, 255, 0.16); width: 100%; max-width: 1600px; height: 76px; margin: 0 auto 0.5rem auto; background: #121816; border: 1px solid #26382f; border-radius: 6px; overflow: hidden; min-width: 0; flex: 0 0 auto; }
|
.visualizer-shell { --visualizer-bg: rgba(10, 14, 12, 0.9); --visualizer-accent: #44ee88; --visualizer-secondary: #66aaff; --visualizer-muted: rgba(255, 255, 255, 0.16); position: relative; isolation: isolate; width: 100%; max-width: 1600px; height: 118px; margin: 0 auto 0.5rem auto; background: #121816; border: 1px solid #26382f; border-radius: 6px; overflow: hidden; min-width: 0; flex: 0 0 auto; box-shadow: inset 0 0 0 1px rgba(255,255,255,0.03), 0 12px 30px rgba(0,0,0,0.18); }
|
||||||
#visualizer-canvas { display: block; width: 100%; height: 100%; }
|
#visualizer-canvas { display: block; width: 100%; height: 100%; }
|
||||||
|
.visualizer-fullscreen-btn { position: absolute; top: 0.45rem; right: 0.45rem; z-index: 2; width: 30px; height: 30px; padding: 0; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,0.42); border: 1px solid rgba(255,255,255,0.24); border-radius: 4px; color: #fff; font-size: 1rem; line-height: 1; opacity: 0; transition: opacity 0.15s, background 0.15s, transform 0.15s; backdrop-filter: blur(4px); }
|
||||||
|
.visualizer-shell:hover .visualizer-fullscreen-btn,
|
||||||
|
.visualizer-shell:focus-within .visualizer-fullscreen-btn,
|
||||||
|
.visualizer-shell.is-fullscreen .visualizer-fullscreen-btn,
|
||||||
|
.visualizer-shell:fullscreen .visualizer-fullscreen-btn { opacity: 0.86; }
|
||||||
|
.visualizer-fullscreen-btn:hover { background: rgba(0,0,0,0.68); transform: scale(1.04); }
|
||||||
|
.visualizer-shell.is-fullscreen,
|
||||||
|
.visualizer-shell:fullscreen,
|
||||||
|
.visualizer-shell:-webkit-full-screen { width: 100vw; height: 100vh; max-width: none; margin: 0; border: 0; border-radius: 0; background: #050505; box-shadow: none; }
|
||||||
|
.visualizer-shell.is-fullscreen { position: fixed; inset: 0; z-index: 3000; }
|
||||||
|
.visualizer-shell.is-fullscreen #visualizer-canvas,
|
||||||
|
.visualizer-shell:fullscreen #visualizer-canvas,
|
||||||
|
.visualizer-shell:-webkit-full-screen #visualizer-canvas { width: 100vw; height: 100vh; }
|
||||||
|
.visualizer-shell.is-fullscreen .visualizer-fullscreen-btn,
|
||||||
|
.visualizer-shell:fullscreen .visualizer-fullscreen-btn,
|
||||||
|
.visualizer-shell:-webkit-full-screen .visualizer-fullscreen-btn { top: 1rem; right: 1rem; width: 40px; height: 40px; font-size: 1.4rem; }
|
||||||
#player-bar { background: #1a1a1a; border-radius: 6px; padding: 0.5rem 0.75rem; display: flex; gap: 0.75rem; align-items: center; min-width: 0; max-width: 100%; overflow-x: hidden; }
|
#player-bar { background: #1a1a1a; border-radius: 6px; padding: 0.5rem 0.75rem; display: flex; gap: 0.75rem; align-items: center; min-width: 0; max-width: 100%; overflow-x: hidden; }
|
||||||
#now-playing { width: 180px; flex: 0 1 180px; min-width: 0; }
|
#now-playing { width: 180px; flex: 0 1 180px; min-width: 0; }
|
||||||
#channel-name { font-size: 0.7rem; color: #666; margin-bottom: 0.1rem; }
|
#channel-name { font-size: 0.7rem; color: #666; margin-bottom: 0.1rem; }
|
||||||
|
|
@ -545,7 +561,7 @@ button:hover { background: #333; }
|
||||||
|
|
||||||
/* Player bar - stacked layout */
|
/* Player bar - stacked layout */
|
||||||
.visualizer-shell {
|
.visualizer-shell {
|
||||||
height: 54px;
|
height: 74px;
|
||||||
margin: 0 0 0.3rem 0;
|
margin: 0 0 0.3rem 0;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
@ -711,6 +727,12 @@ body[data-ui-theme]:not([data-ui-theme="default"]) .track-actions .track-preview
|
||||||
color: var(--control-color, var(--text-color, #eee));
|
color: var(--control-color, var(--text-color, #eee));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) .visualizer-fullscreen-btn {
|
||||||
|
background: rgba(0,0,0,0.48);
|
||||||
|
border-color: rgba(255,255,255,0.28);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
body[data-ui-theme]:not([data-ui-theme="default"]) .track:hover,
|
body[data-ui-theme]:not([data-ui-theme="default"]) .track:hover,
|
||||||
body[data-ui-theme]:not([data-ui-theme="default"]) .playlist-item:hover,
|
body[data-ui-theme]:not([data-ui-theme="default"]) .playlist-item:hover,
|
||||||
body[data-ui-theme]:not([data-ui-theme="default"]) .context-menu-item:hover {
|
body[data-ui-theme]:not([data-ui-theme="default"]) .context-menu-item:hover {
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,15 @@
|
||||||
|
|
||||||
const MODES = new Set(["off", "bars", "wave", "radial", "pulse"]);
|
const MODES = new Set(["off", "bars", "wave", "radial", "pulse"]);
|
||||||
const COOKIE_MAX_AGE = 60 * 60 * 24 * 400;
|
const COOKIE_MAX_AGE = 60 * 60 * 24 * 400;
|
||||||
|
const TWO_PI = Math.PI * 2;
|
||||||
|
const PARTICLE_COUNT = 140;
|
||||||
|
|
||||||
let mode = "off";
|
let mode = "off";
|
||||||
let shell = null;
|
let shell = null;
|
||||||
let canvas = null;
|
let canvas = null;
|
||||||
let ctx = null;
|
let ctx = null;
|
||||||
let selector = null;
|
let selector = null;
|
||||||
|
let fullscreenButton = null;
|
||||||
let audioContext = null;
|
let audioContext = null;
|
||||||
let source = null;
|
let source = null;
|
||||||
let analyser = null;
|
let analyser = null;
|
||||||
|
|
@ -19,6 +22,12 @@
|
||||||
let waveformData = null;
|
let waveformData = null;
|
||||||
let animationId = 0;
|
let animationId = 0;
|
||||||
let graphUnavailable = false;
|
let graphUnavailable = false;
|
||||||
|
let particles = [];
|
||||||
|
let rememberedWidth = 0;
|
||||||
|
let rememberedHeight = 0;
|
||||||
|
let beatMemory = 0;
|
||||||
|
let energyMemory = 0;
|
||||||
|
let frame = 0;
|
||||||
|
|
||||||
function normalizeMode(value) {
|
function normalizeMode(value) {
|
||||||
return MODES.has(value) ? value : "off";
|
return MODES.has(value) ? value : "off";
|
||||||
|
|
@ -71,12 +80,13 @@
|
||||||
const raw = styles.getPropertyValue(name).trim();
|
const raw = styles.getPropertyValue(name).trim();
|
||||||
return raw && !raw.includes("var(") ? raw : "";
|
return raw && !raw.includes("var(") ? raw : "";
|
||||||
};
|
};
|
||||||
const accent = value("--visualizer-accent") || value("--accent") || "#44ee88";
|
|
||||||
const secondary = value("--visualizer-secondary") || value("--warning") || "#66aaff";
|
|
||||||
const background = value("--visualizer-bg") || "rgba(10, 10, 10, 0.82)";
|
|
||||||
const muted = value("--visualizer-muted") || "rgba(255, 255, 255, 0.16)";
|
|
||||||
|
|
||||||
return { accent, secondary, background, muted };
|
return {
|
||||||
|
accent: value("--visualizer-accent") || value("--accent") || "#44ee88",
|
||||||
|
secondary: value("--visualizer-secondary") || value("--warning") || "#66aaff",
|
||||||
|
background: value("--visualizer-bg") || "rgba(10, 10, 10, 0.82)",
|
||||||
|
muted: value("--visualizer-muted") || "rgba(255, 255, 255, 0.16)"
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function resizeCanvas() {
|
function resizeCanvas() {
|
||||||
|
|
@ -92,6 +102,12 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
|
||||||
|
if (rememberedWidth !== canvas.clientWidth || rememberedHeight !== canvas.clientHeight) {
|
||||||
|
rememberedWidth = canvas.clientWidth;
|
||||||
|
rememberedHeight = canvas.clientHeight;
|
||||||
|
seedParticles(rememberedWidth, rememberedHeight);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearCanvas() {
|
function clearCanvas() {
|
||||||
|
|
@ -100,6 +116,27 @@
|
||||||
ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight);
|
ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function seedParticles(width, height) {
|
||||||
|
particles = [];
|
||||||
|
const safeWidth = Math.max(width, 1);
|
||||||
|
const safeHeight = Math.max(height, 1);
|
||||||
|
|
||||||
|
for (let i = 0; i < PARTICLE_COUNT; i++) {
|
||||||
|
particles.push({
|
||||||
|
x: Math.random() * safeWidth,
|
||||||
|
y: Math.random() * safeHeight,
|
||||||
|
px: Math.random() * safeWidth,
|
||||||
|
py: Math.random() * safeHeight,
|
||||||
|
vx: (Math.random() - 0.5) * 0.55,
|
||||||
|
vy: (Math.random() - 0.5) * 0.55,
|
||||||
|
size: 0.8 + Math.random() * 2.4,
|
||||||
|
phase: Math.random() * TWO_PI,
|
||||||
|
spin: (Math.random() - 0.5) * 0.04,
|
||||||
|
lane: Math.random()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function initAudioGraph() {
|
function initAudioGraph() {
|
||||||
if (source) return true;
|
if (source) return true;
|
||||||
if (graphUnavailable) return false;
|
if (graphUnavailable) return false;
|
||||||
|
|
@ -113,10 +150,10 @@
|
||||||
try {
|
try {
|
||||||
audioContext = audioContext || new AudioContextCtor();
|
audioContext = audioContext || new AudioContextCtor();
|
||||||
analyser = audioContext.createAnalyser();
|
analyser = audioContext.createAnalyser();
|
||||||
analyser.fftSize = 1024;
|
analyser.fftSize = 2048;
|
||||||
analyser.minDecibels = -92;
|
analyser.minDecibels = -96;
|
||||||
analyser.maxDecibels = -12;
|
analyser.maxDecibels = -10;
|
||||||
analyser.smoothingTimeConstant = 0.82;
|
analyser.smoothingTimeConstant = 0.68;
|
||||||
|
|
||||||
source = audioContext.createMediaElementSource(M.audio);
|
source = audioContext.createMediaElementSource(M.audio);
|
||||||
source.connect(analyser);
|
source.connect(analyser);
|
||||||
|
|
@ -147,151 +184,427 @@
|
||||||
|
|
||||||
function average(data, start, end) {
|
function average(data, start, end) {
|
||||||
let total = 0;
|
let total = 0;
|
||||||
const last = Math.min(end, data.length);
|
const first = Math.max(0, Math.min(start, data.length));
|
||||||
for (let i = start; i < last; i++) total += data[i];
|
const last = Math.max(first + 1, Math.min(end, data.length));
|
||||||
return total / Math.max(1, last - start);
|
for (let i = first; i < last; i++) total += data[i];
|
||||||
|
return total / (last - first);
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawIdle(width, height, colors, timestamp) {
|
function bandAverage(startRatio, endRatio) {
|
||||||
const bars = Math.max(12, Math.floor(width / 18));
|
if (!frequencyData) return 0;
|
||||||
const gap = 3;
|
const start = Math.floor(Math.max(0, Math.min(1, startRatio)) * frequencyData.length);
|
||||||
const barWidth = Math.max(3, (width - gap * (bars - 1)) / bars);
|
const end = Math.floor(Math.max(0, Math.min(1, endRatio)) * frequencyData.length);
|
||||||
|
return average(frequencyData, start, Math.max(start + 1, end)) / 255;
|
||||||
|
}
|
||||||
|
|
||||||
ctx.globalAlpha = 0.7;
|
function bandValue(index, count) {
|
||||||
ctx.fillStyle = colors.muted;
|
if (!frequencyData) return 0;
|
||||||
for (let i = 0; i < bars; i++) {
|
const a = Math.pow(index / count, 1.75);
|
||||||
const phase = timestamp / 600 + i * 0.55;
|
const b = Math.pow((index + 1) / count, 1.75);
|
||||||
const value = 0.16 + (Math.sin(phase) + 1) * 0.08;
|
const start = Math.floor(a * frequencyData.length);
|
||||||
const barHeight = Math.max(2, height * value);
|
const end = Math.max(start + 2, Math.floor(b * frequencyData.length));
|
||||||
const x = i * (barWidth + gap);
|
const raw = average(frequencyData, start, end) / 255;
|
||||||
ctx.fillRect(x, height - barHeight, barWidth, barHeight);
|
return Math.pow(raw, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readAudioState(timestamp, hasLiveGraph) {
|
||||||
|
if (hasLiveGraph) {
|
||||||
|
analyser.getByteFrequencyData(frequencyData);
|
||||||
|
analyser.getByteTimeDomainData(waveformData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const bass = hasLiveGraph ? bandAverage(0.005, 0.06) : 0.55 + Math.sin(timestamp / 310) * 0.25;
|
||||||
|
const mids = hasLiveGraph ? bandAverage(0.06, 0.36) : 0.45 + Math.sin(timestamp / 470 + 1.4) * 0.22;
|
||||||
|
const treble = hasLiveGraph ? bandAverage(0.36, 0.92) : 0.38 + Math.sin(timestamp / 230 + 2.2) * 0.18;
|
||||||
|
const energy = Math.max(0, Math.min(1, bass * 0.42 + mids * 0.36 + treble * 0.22));
|
||||||
|
|
||||||
|
beatMemory = Math.max(beatMemory * 0.9, bass);
|
||||||
|
energyMemory = energyMemory * 0.82 + energy * 0.18;
|
||||||
|
|
||||||
|
return {
|
||||||
|
bass,
|
||||||
|
mids,
|
||||||
|
treble,
|
||||||
|
energy,
|
||||||
|
beat: beatMemory,
|
||||||
|
smooth: energyMemory,
|
||||||
|
live: hasLiveGraph,
|
||||||
|
time: timestamp / 1000
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillWithTrails(width, height, colors, state) {
|
||||||
|
ctx.globalCompositeOperation = "source-over";
|
||||||
|
ctx.globalAlpha = state.live ? 0.24 : 0.42;
|
||||||
|
ctx.fillStyle = colors.background;
|
||||||
|
ctx.fillRect(0, 0, width, height);
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
|
||||||
|
const glow = ctx.createRadialGradient(
|
||||||
|
width * (0.48 + Math.sin(state.time * 0.4) * 0.08),
|
||||||
|
height * (0.52 + Math.cos(state.time * 0.33) * 0.14),
|
||||||
|
1,
|
||||||
|
width * 0.5,
|
||||||
|
height * 0.5,
|
||||||
|
Math.max(width, height) * (0.4 + state.energy * 0.25)
|
||||||
|
);
|
||||||
|
glow.addColorStop(0, colors.secondary);
|
||||||
|
glow.addColorStop(0.38, colors.accent);
|
||||||
|
glow.addColorStop(1, "transparent");
|
||||||
|
ctx.globalAlpha = 0.08 + state.energy * 0.1;
|
||||||
|
ctx.fillStyle = glow;
|
||||||
|
ctx.fillRect(0, 0, width, height);
|
||||||
ctx.globalAlpha = 1;
|
ctx.globalAlpha = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawBars(width, height, colors) {
|
function drawGrid(width, height, colors, state) {
|
||||||
analyser.getByteFrequencyData(frequencyData);
|
const spacing = Math.max(12, Math.min(34, width / 34));
|
||||||
|
const offset = (state.time * (30 + state.treble * 80)) % spacing;
|
||||||
const barCount = Math.max(20, Math.min(72, Math.floor(width / 8)));
|
|
||||||
const gap = 2;
|
|
||||||
const barWidth = Math.max(2, (width - gap * (barCount - 1)) / barCount);
|
|
||||||
const sampleSize = Math.max(1, Math.floor(frequencyData.length / barCount));
|
|
||||||
const gradient = ctx.createLinearGradient(0, height, 0, 0);
|
|
||||||
gradient.addColorStop(0, colors.accent);
|
|
||||||
gradient.addColorStop(1, colors.secondary);
|
|
||||||
ctx.fillStyle = gradient;
|
|
||||||
|
|
||||||
for (let i = 0; i < barCount; i++) {
|
|
||||||
const value = average(frequencyData, i * sampleSize, (i + 1) * sampleSize) / 255;
|
|
||||||
const barHeight = Math.max(2, value * height * 0.9);
|
|
||||||
const x = i * (barWidth + gap);
|
|
||||||
ctx.fillRect(x, height - barHeight, barWidth, barHeight);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawWave(width, height, colors) {
|
|
||||||
analyser.getByteTimeDomainData(waveformData);
|
|
||||||
|
|
||||||
ctx.lineWidth = 2;
|
|
||||||
ctx.strokeStyle = colors.accent;
|
|
||||||
ctx.beginPath();
|
|
||||||
|
|
||||||
for (let i = 0; i < waveformData.length; i++) {
|
|
||||||
const x = i / (waveformData.length - 1) * width;
|
|
||||||
const y = waveformData[i] / 255 * height;
|
|
||||||
if (i === 0) ctx.moveTo(x, y);
|
|
||||||
else ctx.lineTo(x, y);
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
ctx.globalAlpha = 0.35;
|
|
||||||
ctx.strokeStyle = colors.secondary;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(0, height / 2);
|
|
||||||
ctx.lineTo(width, height / 2);
|
|
||||||
ctx.stroke();
|
|
||||||
ctx.globalAlpha = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawRadial(width, height, colors) {
|
|
||||||
analyser.getByteFrequencyData(frequencyData);
|
|
||||||
|
|
||||||
const cx = width / 2;
|
|
||||||
const cy = height / 2;
|
|
||||||
const radius = Math.max(12, Math.min(width, height) * 0.22);
|
|
||||||
const spokes = 96;
|
|
||||||
const sampleSize = Math.max(1, Math.floor(frequencyData.length / spokes));
|
|
||||||
|
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.translate(cx, cy);
|
ctx.globalAlpha = 0.11 + state.energy * 0.08;
|
||||||
ctx.lineCap = "round";
|
ctx.strokeStyle = colors.muted;
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
|
||||||
for (let i = 0; i < spokes; i++) {
|
for (let x = -spacing + offset; x < width + spacing; x += spacing) {
|
||||||
const value = average(frequencyData, i * sampleSize, (i + 1) * sampleSize) / 255;
|
|
||||||
const length = radius + value * Math.min(width, height) * 0.32;
|
|
||||||
const angle = i / spokes * Math.PI * 2;
|
|
||||||
const hueColor = i % 2 === 0 ? colors.accent : colors.secondary;
|
|
||||||
|
|
||||||
ctx.rotate(angle - (i === 0 ? 0 : (i - 1) / spokes * Math.PI * 2));
|
|
||||||
ctx.strokeStyle = hueColor;
|
|
||||||
ctx.lineWidth = Math.max(1, 1 + value * 3);
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(radius, 0);
|
ctx.moveTo(x, 0);
|
||||||
ctx.lineTo(length, 0);
|
ctx.lineTo(x + Math.sin(state.time + x * 0.01) * 18, height);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let y = height + spacing - offset; y > -spacing; y -= spacing) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, y);
|
||||||
|
ctx.lineTo(width, y + Math.cos(state.time + y * 0.02) * 8);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.globalAlpha = 0.09 + state.beat * 0.08;
|
||||||
|
ctx.strokeStyle = colors.secondary;
|
||||||
|
for (let y = 0; y < height; y += 4) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, y);
|
||||||
|
ctx.lineTo(width, y);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.globalAlpha = 0.35;
|
|
||||||
ctx.strokeStyle = colors.muted;
|
|
||||||
ctx.lineWidth = 1;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(0, 0, radius, 0, Math.PI * 2);
|
|
||||||
ctx.stroke();
|
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
ctx.globalAlpha = 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawPulse(width, height, colors) {
|
function drawParticles(width, height, colors, state, intensity = 1) {
|
||||||
analyser.getByteFrequencyData(frequencyData);
|
if (!particles.length) seedParticles(width, height);
|
||||||
analyser.getByteTimeDomainData(waveformData);
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalCompositeOperation = "lighter";
|
||||||
|
|
||||||
|
for (const p of particles) {
|
||||||
|
p.px = p.x;
|
||||||
|
p.py = p.y;
|
||||||
|
|
||||||
|
const wave = Math.sin(state.time * 2.2 + p.phase) * 0.65 + Math.cos(state.time + p.lane * 8) * 0.35;
|
||||||
|
const speed = (0.6 + state.energy * 3.2) * intensity;
|
||||||
|
p.vx += Math.cos(p.phase + state.time * (0.8 + p.lane)) * 0.015 * speed;
|
||||||
|
p.vy += Math.sin(p.phase * 1.7 + state.time * 1.15) * 0.018 * speed;
|
||||||
|
p.x += p.vx * speed + wave * state.treble * 1.8;
|
||||||
|
p.y += p.vy * speed + Math.sin(state.time * 1.4 + p.x * 0.015) * state.bass * 1.6;
|
||||||
|
p.vx *= 0.965;
|
||||||
|
p.vy *= 0.965;
|
||||||
|
p.phase += p.spin + state.energy * 0.012;
|
||||||
|
|
||||||
|
if (p.x < -20) p.x = width + 20;
|
||||||
|
if (p.x > width + 20) p.x = -20;
|
||||||
|
if (p.y < -20) p.y = height + 20;
|
||||||
|
if (p.y > height + 20) p.y = -20;
|
||||||
|
|
||||||
|
const useSecondary = p.lane > 0.52;
|
||||||
|
ctx.strokeStyle = useSecondary ? colors.secondary : colors.accent;
|
||||||
|
ctx.fillStyle = useSecondary ? colors.secondary : colors.accent;
|
||||||
|
ctx.globalAlpha = 0.14 + state.energy * 0.34;
|
||||||
|
ctx.lineWidth = Math.max(0.8, p.size * (0.5 + state.beat * 1.4));
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(p.px, p.py);
|
||||||
|
ctx.lineTo(p.x, p.y);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
if (frame % 2 === 0 || state.energy > 0.52) {
|
||||||
|
ctx.globalAlpha = 0.16 + state.treble * 0.45;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(p.x, p.y, p.size * (0.7 + state.beat * 1.8), 0, TWO_PI);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
ctx.globalCompositeOperation = "source-over";
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawSpectrumRibbon(width, height, colors, state, flipped = false) {
|
||||||
|
const count = Math.max(44, Math.min(150, Math.floor(width / 7)));
|
||||||
|
const step = width / count;
|
||||||
|
const baseY = flipped ? height * 0.74 : height * 0.26;
|
||||||
|
const direction = flipped ? -1 : 1;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalCompositeOperation = "lighter";
|
||||||
|
ctx.strokeStyle = flipped ? colors.secondary : colors.accent;
|
||||||
|
ctx.fillStyle = flipped ? colors.secondary : colors.accent;
|
||||||
|
ctx.lineWidth = 1.4 + state.energy * 2.4;
|
||||||
|
ctx.globalAlpha = 0.33 + state.energy * 0.25;
|
||||||
|
ctx.beginPath();
|
||||||
|
|
||||||
|
for (let i = 0; i <= count; i++) {
|
||||||
|
const value = state.live ? bandValue(i % count, count) : 0.35 + Math.sin(state.time * 3 + i * 0.27) * 0.26;
|
||||||
|
const x = i * step;
|
||||||
|
const y = baseY + direction * (value * height * 0.3 + Math.sin(state.time * 2 + i * 0.18) * height * 0.035);
|
||||||
|
if (i === 0) ctx.moveTo(x, baseY);
|
||||||
|
ctx.lineTo(x, y);
|
||||||
|
}
|
||||||
|
ctx.lineTo(width, baseY);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.globalAlpha = 0.78;
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawWaveformRibbon(width, height, colors, state, amplitude = 0.32, offset = 0, color = colors.accent) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalCompositeOperation = "lighter";
|
||||||
|
ctx.strokeStyle = color;
|
||||||
|
ctx.lineWidth = 1.2 + state.energy * 3.2;
|
||||||
|
ctx.globalAlpha = 0.4 + state.energy * 0.36;
|
||||||
|
ctx.beginPath();
|
||||||
|
|
||||||
|
const samples = state.live && waveformData ? waveformData.length : 720;
|
||||||
|
const step = Math.max(2, Math.floor(samples / Math.max(90, Math.min(240, width / 4))));
|
||||||
|
let first = true;
|
||||||
|
|
||||||
|
for (let i = 0; i < samples; i += step) {
|
||||||
|
const t = i / Math.max(1, samples - 1);
|
||||||
|
const raw = state.live ? (waveformData[i] - 128) / 128 : Math.sin(t * TWO_PI * 5 + state.time * 3 + offset) * 0.55 + Math.sin(t * TWO_PI * 17 - state.time * 2) * 0.15;
|
||||||
|
const x = t * width;
|
||||||
|
const y = height * 0.5 + raw * height * amplitude + Math.sin(t * TWO_PI * 2 + state.time + offset) * state.bass * height * 0.08;
|
||||||
|
if (first) {
|
||||||
|
ctx.moveTo(x, y);
|
||||||
|
first = false;
|
||||||
|
} else {
|
||||||
|
ctx.lineTo(x, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawBars(width, height, colors, state) {
|
||||||
|
drawGrid(width, height, colors, state);
|
||||||
|
drawSpectrumRibbon(width, height, colors, state, false);
|
||||||
|
drawSpectrumRibbon(width, height, colors, state, true);
|
||||||
|
|
||||||
|
const count = Math.max(56, Math.min(180, Math.floor(width / 5)));
|
||||||
|
const gap = Math.max(1, Math.min(3, width / 380));
|
||||||
|
const barWidth = Math.max(2, width / count - gap);
|
||||||
|
const centerY = height / 2;
|
||||||
|
const gradient = ctx.createLinearGradient(0, height, 0, 0);
|
||||||
|
gradient.addColorStop(0, colors.secondary);
|
||||||
|
gradient.addColorStop(0.5, colors.accent);
|
||||||
|
gradient.addColorStop(1, colors.secondary);
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalCompositeOperation = "lighter";
|
||||||
|
ctx.shadowColor = colors.accent;
|
||||||
|
ctx.shadowBlur = 10 + state.beat * 20;
|
||||||
|
ctx.fillStyle = gradient;
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const value = state.live ? bandValue(i, count) : 0.28 + Math.sin(state.time * 4 + i * 0.21) * 0.25 + Math.sin(state.time * 2.1 + i * 0.07) * 0.16;
|
||||||
|
const pulse = Math.max(0, Math.min(1, value + state.beat * 0.18));
|
||||||
|
const barHeight = Math.max(2, pulse * height * 0.47);
|
||||||
|
const x = i * (barWidth + gap);
|
||||||
|
const skew = Math.sin(state.time * 3 + i * 0.25) * state.treble * 5;
|
||||||
|
|
||||||
|
ctx.globalAlpha = 0.45 + pulse * 0.55;
|
||||||
|
ctx.fillRect(x + skew, centerY - barHeight, barWidth, barHeight);
|
||||||
|
ctx.fillRect(x - skew, centerY, barWidth, barHeight);
|
||||||
|
|
||||||
|
if (i % 3 === 0) {
|
||||||
|
ctx.globalAlpha = 0.35 + state.treble * 0.4;
|
||||||
|
ctx.fillRect(x, centerY - barHeight - 4 - state.treble * 9, barWidth, 2);
|
||||||
|
ctx.fillRect(x, centerY + barHeight + 2 + state.treble * 9, barWidth, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
drawWaveformRibbon(width, height, colors, state, 0.18, 0, colors.secondary);
|
||||||
|
drawWaveformRibbon(width, height, colors, state, 0.1, Math.PI, colors.accent);
|
||||||
|
drawParticles(width, height, colors, state, 1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawWave(width, height, colors, state) {
|
||||||
|
drawGrid(width, height, colors, state);
|
||||||
|
drawSpectrumRibbon(width, height, colors, state, false);
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalCompositeOperation = "lighter";
|
||||||
|
for (let layer = 0; layer < 6; layer++) {
|
||||||
|
const color = layer % 2 ? colors.secondary : colors.accent;
|
||||||
|
drawWaveformRibbon(width, height, colors, state, 0.13 + layer * 0.045, layer * 0.9 + state.time * 0.4, color);
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
const nodes = 34;
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalCompositeOperation = "lighter";
|
||||||
|
for (let i = 0; i < nodes; i++) {
|
||||||
|
const t = i / (nodes - 1);
|
||||||
|
const sampleIndex = state.live ? Math.floor(t * (waveformData.length - 1)) : 0;
|
||||||
|
const wave = state.live ? (waveformData[sampleIndex] - 128) / 128 : Math.sin(t * TWO_PI * 8 + state.time * 3);
|
||||||
|
const value = state.live ? bandValue(i, nodes) : Math.abs(wave);
|
||||||
|
const x = t * width;
|
||||||
|
const y = height * 0.5 + wave * height * 0.28;
|
||||||
|
const radius = 2 + value * 13 + state.beat * 6;
|
||||||
|
|
||||||
|
ctx.globalAlpha = 0.14 + value * 0.55;
|
||||||
|
ctx.fillStyle = i % 2 ? colors.secondary : colors.accent;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x, y, radius, 0, TWO_PI);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
drawParticles(width, height, colors, state, 1.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawRadial(width, height, colors, state) {
|
||||||
|
drawGrid(width, height, colors, state);
|
||||||
|
|
||||||
const bass = average(frequencyData, 1, 18) / 255;
|
|
||||||
const mids = average(frequencyData, 18, 96) / 255;
|
|
||||||
const cx = width / 2;
|
const cx = width / 2;
|
||||||
const cy = height / 2;
|
const cy = height / 2;
|
||||||
const radius = Math.max(8, Math.min(width, height) * (0.16 + bass * 0.26));
|
const radius = Math.max(14, Math.min(width, height) * (0.14 + state.bass * 0.08));
|
||||||
|
const spokes = 192;
|
||||||
|
|
||||||
ctx.globalAlpha = 0.28;
|
ctx.save();
|
||||||
ctx.fillStyle = colors.secondary;
|
ctx.translate(cx, cy);
|
||||||
ctx.beginPath();
|
ctx.rotate(state.time * (0.18 + state.treble * 0.22));
|
||||||
ctx.arc(cx, cy, radius * 1.75, 0, Math.PI * 2);
|
ctx.globalCompositeOperation = "lighter";
|
||||||
ctx.fill();
|
ctx.lineCap = "round";
|
||||||
|
|
||||||
ctx.globalAlpha = 0.9;
|
for (let ring = 0; ring < 3; ring++) {
|
||||||
|
const ringRadius = radius + ring * Math.min(width, height) * 0.105 + state.beat * 18;
|
||||||
|
ctx.globalAlpha = 0.18 + ring * 0.08;
|
||||||
|
ctx.strokeStyle = ring % 2 ? colors.secondary : colors.accent;
|
||||||
|
ctx.lineWidth = 1 + state.energy * 2.4;
|
||||||
|
ctx.beginPath();
|
||||||
|
for (let i = 0; i <= spokes; i++) {
|
||||||
|
const value = state.live ? bandValue(i % spokes, spokes) : 0.34 + Math.sin(state.time * 4 + i * 0.16 + ring) * 0.24;
|
||||||
|
const angle = i / spokes * TWO_PI;
|
||||||
|
const waveRadius = ringRadius + value * Math.min(width, height) * (0.08 + ring * 0.035);
|
||||||
|
const x = Math.cos(angle) * waveRadius;
|
||||||
|
const y = Math.sin(angle) * waveRadius;
|
||||||
|
if (i === 0) ctx.moveTo(x, y);
|
||||||
|
else ctx.lineTo(x, y);
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < spokes; i++) {
|
||||||
|
const value = state.live ? bandValue(i, spokes) : 0.28 + Math.sin(state.time * 6 + i * 0.23) * 0.24;
|
||||||
|
const angle = i / spokes * TWO_PI;
|
||||||
|
const start = radius * (0.68 + Math.sin(state.time + i) * 0.04);
|
||||||
|
const end = start + value * Math.min(width, height) * 0.42 + state.beat * 28;
|
||||||
|
|
||||||
|
ctx.strokeStyle = i % 2 ? colors.secondary : colors.accent;
|
||||||
|
ctx.globalAlpha = 0.16 + value * 0.72;
|
||||||
|
ctx.lineWidth = 0.9 + value * 3.8;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(Math.cos(angle) * start, Math.sin(angle) * start);
|
||||||
|
ctx.lineTo(Math.cos(angle) * end, Math.sin(angle) * end);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.globalAlpha = 0.22 + state.beat * 0.26;
|
||||||
ctx.fillStyle = colors.accent;
|
ctx.fillStyle = colors.accent;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
|
ctx.arc(0, 0, radius * (0.55 + state.beat * 0.45), 0, TWO_PI);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
drawParticles(width, height, colors, state, 1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawPulse(width, height, colors, state) {
|
||||||
|
drawGrid(width, height, colors, state);
|
||||||
|
|
||||||
|
const cx = width / 2;
|
||||||
|
const cy = height / 2;
|
||||||
|
const maxRadius = Math.hypot(width, height) * 0.55;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalCompositeOperation = "lighter";
|
||||||
|
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const phase = (state.time * (0.18 + state.energy * 0.24) + i / 10) % 1;
|
||||||
|
const radius = phase * maxRadius;
|
||||||
|
ctx.globalAlpha = (1 - phase) * (0.1 + state.beat * 0.22);
|
||||||
|
ctx.strokeStyle = i % 2 ? colors.secondary : colors.accent;
|
||||||
|
ctx.lineWidth = 1 + state.energy * 5;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, radius, 0, TWO_PI);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
const rays = 96;
|
||||||
|
for (let i = 0; i < rays; i++) {
|
||||||
|
const value = state.live ? bandValue(i, rays) : 0.32 + Math.sin(state.time * 7 + i * 0.31) * 0.25;
|
||||||
|
const angle = i / rays * TWO_PI + state.time * 0.25;
|
||||||
|
const length = Math.min(width, height) * (0.16 + value * 0.7 + state.beat * 0.22);
|
||||||
|
const wobble = Math.sin(state.time * 3 + i * 0.4) * state.treble * 18;
|
||||||
|
ctx.globalAlpha = 0.15 + value * 0.58;
|
||||||
|
ctx.strokeStyle = i % 2 ? colors.secondary : colors.accent;
|
||||||
|
ctx.lineWidth = 0.9 + value * 3.5;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(cx + Math.cos(angle) * 8, cy + Math.sin(angle) * 8);
|
||||||
|
ctx.lineTo(cx + Math.cos(angle) * (length + wobble), cy + Math.sin(angle) * (length - wobble));
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
const coreRadius = Math.max(8, Math.min(width, height) * (0.1 + state.beat * 0.18));
|
||||||
|
ctx.globalAlpha = 0.3 + state.beat * 0.45;
|
||||||
|
ctx.fillStyle = colors.secondary;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, coreRadius * 2.2, 0, TWO_PI);
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
|
|
||||||
ctx.globalAlpha = 0.75;
|
ctx.globalAlpha = 0.8;
|
||||||
ctx.strokeStyle = colors.secondary;
|
ctx.fillStyle = colors.accent;
|
||||||
ctx.lineWidth = 1 + mids * 4;
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
for (let i = 0; i < waveformData.length; i += 4) {
|
ctx.arc(cx, cy, coreRadius, 0, TWO_PI);
|
||||||
const x = i / (waveformData.length - 1) * width;
|
ctx.fill();
|
||||||
const y = height * 0.5 + (waveformData[i] - 128) / 128 * height * 0.32;
|
ctx.restore();
|
||||||
if (i === 0) ctx.moveTo(x, y);
|
|
||||||
else ctx.lineTo(x, y);
|
drawWaveformRibbon(width, height, colors, state, 0.2, state.time, colors.secondary);
|
||||||
}
|
drawParticles(width, height, colors, state, 1.6);
|
||||||
ctx.stroke();
|
}
|
||||||
ctx.globalAlpha = 1;
|
|
||||||
|
function drawIdle(width, height, colors, timestamp) {
|
||||||
|
const state = readAudioState(timestamp, false);
|
||||||
|
fillWithTrails(width, height, colors, state);
|
||||||
|
drawGrid(width, height, colors, state);
|
||||||
|
|
||||||
|
if (mode === "radial") drawRadial(width, height, colors, state);
|
||||||
|
else if (mode === "pulse") drawPulse(width, height, colors, state);
|
||||||
|
else if (mode === "wave") drawWave(width, height, colors, state);
|
||||||
|
else drawBars(width, height, colors, state);
|
||||||
}
|
}
|
||||||
|
|
||||||
function draw(timestamp) {
|
function draw(timestamp) {
|
||||||
animationId = requestAnimationFrame(draw);
|
animationId = requestAnimationFrame(draw);
|
||||||
if (!canvas || !ctx || mode === "off") return;
|
if (!canvas || !ctx || mode === "off") return;
|
||||||
|
|
||||||
|
frame++;
|
||||||
resizeCanvas();
|
resizeCanvas();
|
||||||
const width = canvas.clientWidth;
|
const width = canvas.clientWidth;
|
||||||
const height = canvas.clientHeight;
|
const height = canvas.clientHeight;
|
||||||
|
|
@ -303,19 +616,59 @@
|
||||||
&& !M.audio.paused
|
&& !M.audio.paused
|
||||||
&& !!M.audio.src;
|
&& !!M.audio.src;
|
||||||
|
|
||||||
ctx.clearRect(0, 0, width, height);
|
|
||||||
ctx.fillStyle = colors.background;
|
|
||||||
ctx.fillRect(0, 0, width, height);
|
|
||||||
|
|
||||||
if (!hasLiveGraph) {
|
if (!hasLiveGraph) {
|
||||||
drawIdle(width, height, colors, timestamp);
|
drawIdle(width, height, colors, timestamp);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mode === "wave") drawWave(width, height, colors);
|
const state = readAudioState(timestamp, true);
|
||||||
else if (mode === "radial") drawRadial(width, height, colors);
|
fillWithTrails(width, height, colors, state);
|
||||||
else if (mode === "pulse") drawPulse(width, height, colors);
|
|
||||||
else drawBars(width, height, colors);
|
if (mode === "wave") drawWave(width, height, colors, state);
|
||||||
|
else if (mode === "radial") drawRadial(width, height, colors, state);
|
||||||
|
else if (mode === "pulse") drawPulse(width, height, colors, state);
|
||||||
|
else drawBars(width, height, colors, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFullscreen() {
|
||||||
|
return document.fullscreenElement === shell
|
||||||
|
|| document.webkitFullscreenElement === shell
|
||||||
|
|| shell?.classList.contains("is-fullscreen");
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFullscreenButton() {
|
||||||
|
if (!fullscreenButton || !shell) return;
|
||||||
|
const active = isFullscreen();
|
||||||
|
shell.classList.toggle("is-fullscreen", active);
|
||||||
|
fullscreenButton.textContent = active ? "×" : "⛶";
|
||||||
|
fullscreenButton.title = active ? "Exit fullscreen visualizer" : "Fullscreen visualizer";
|
||||||
|
fullscreenButton.setAttribute("aria-label", fullscreenButton.title);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleFullscreen() {
|
||||||
|
if (!shell) return;
|
||||||
|
if (mode === "off") M.applyVisualizer("bars");
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (document.fullscreenElement || document.webkitFullscreenElement || shell.classList.contains("is-fullscreen")) {
|
||||||
|
if (document.fullscreenElement && document.exitFullscreen) await document.exitFullscreen();
|
||||||
|
else if (document.webkitFullscreenElement && document.webkitExitFullscreen) await document.webkitExitFullscreen();
|
||||||
|
else shell.classList.remove("is-fullscreen");
|
||||||
|
} else if (shell.requestFullscreen) {
|
||||||
|
await shell.requestFullscreen();
|
||||||
|
} else if (shell.webkitRequestFullscreen) {
|
||||||
|
await shell.webkitRequestFullscreen();
|
||||||
|
} else {
|
||||||
|
shell.classList.add("is-fullscreen");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
shell.classList.toggle("is-fullscreen");
|
||||||
|
console.warn("[Visualizer] Fullscreen request failed", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFullscreenButton();
|
||||||
|
resizeCanvas();
|
||||||
|
M.resumeVisualizer?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
M.applyVisualizer = function(nextMode, remember = true) {
|
M.applyVisualizer = function(nextMode, remember = true) {
|
||||||
|
|
@ -351,16 +704,20 @@
|
||||||
M.visualizer = {
|
M.visualizer = {
|
||||||
getMode: () => mode,
|
getMode: () => mode,
|
||||||
hasGraph: () => !!source,
|
hasGraph: () => !!source,
|
||||||
isRunning: () => audioContext?.state === "running"
|
isRunning: () => audioContext?.state === "running",
|
||||||
|
isFullscreen,
|
||||||
|
toggleFullscreen
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
shell = document.getElementById("visualizer-shell");
|
shell = document.getElementById("visualizer-shell");
|
||||||
canvas = document.getElementById("visualizer-canvas");
|
canvas = document.getElementById("visualizer-canvas");
|
||||||
selector = document.getElementById("visualizer-select");
|
selector = document.getElementById("visualizer-select");
|
||||||
|
fullscreenButton = document.getElementById("btn-visualizer-fullscreen");
|
||||||
ctx = canvas?.getContext("2d") || null;
|
ctx = canvas?.getContext("2d") || null;
|
||||||
|
|
||||||
M.applyVisualizer(getSavedMode(), false);
|
M.applyVisualizer(getSavedMode(), false);
|
||||||
|
updateFullscreenButton();
|
||||||
|
|
||||||
if (selector) {
|
if (selector) {
|
||||||
selector.onchange = () => {
|
selector.onchange = () => {
|
||||||
|
|
@ -369,18 +726,42 @@
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (fullscreenButton) {
|
||||||
|
fullscreenButton.onclick = (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
toggleFullscreen();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (window.ResizeObserver && shell) {
|
if (window.ResizeObserver && shell) {
|
||||||
const observer = new ResizeObserver(resizeCanvas);
|
const observer = new ResizeObserver(resizeCanvas);
|
||||||
observer.observe(shell);
|
observer.observe(shell);
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener("resize", resizeCanvas);
|
window.addEventListener("resize", resizeCanvas);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.addEventListener("fullscreenchange", () => {
|
||||||
|
updateFullscreenButton();
|
||||||
|
resizeCanvas();
|
||||||
|
});
|
||||||
|
document.addEventListener("webkitfullscreenchange", () => {
|
||||||
|
updateFullscreenButton();
|
||||||
|
resizeCanvas();
|
||||||
|
});
|
||||||
|
|
||||||
document.addEventListener("pointerdown", () => {
|
document.addEventListener("pointerdown", () => {
|
||||||
if (mode !== "off") M.resumeVisualizer();
|
if (mode !== "off") M.resumeVisualizer();
|
||||||
}, { passive: true });
|
}, { passive: true });
|
||||||
|
|
||||||
document.addEventListener("keydown", () => {
|
document.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key === "Escape" && shell?.classList.contains("is-fullscreen") && !document.fullscreenElement && !document.webkitFullscreenElement) {
|
||||||
|
shell.classList.remove("is-fullscreen");
|
||||||
|
updateFullscreenButton();
|
||||||
|
resizeCanvas();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (mode !== "off") M.resumeVisualizer();
|
if (mode !== "off") M.resumeVisualizer();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue