Feature: Config UI with persistent storage to tweak the visualizer

This commit is contained in:
Dejvino
2026-01-03 22:51:13 +00:00
parent 8ed8ea9d34
commit e7931174de
10 changed files with 517 additions and 44 deletions
+318
View File
@@ -0,0 +1,318 @@
import { state } from '../state.js';
import { SceneFeature } from './SceneFeature.js';
import sceneFeatureManager from './SceneFeatureManager.js';
import { MediaStorage } from '../core/media-storage.js';
import { showStandbyScreen } from './projection-screen.js';
export class ConfigUI extends SceneFeature {
constructor() {
super();
sceneFeatureManager.register(this);
this.toggles = {};
}
init() {
const container = document.createElement('div');
container.id = 'config-ui';
Object.assign(container.style, {
position: 'absolute',
top: '70px',
left: '20px',
zIndex: '1000',
backgroundColor: 'rgba(0, 0, 0, 0.8)',
padding: '15px',
borderRadius: '8px',
color: 'white',
fontFamily: 'sans-serif',
display: 'flex',
flexDirection: 'column',
gap: '10px',
minWidth: '200px'
});
const saveConfig = () => {
localStorage.setItem('partyConfig', JSON.stringify(state.config));
};
const createToggle = (label, configKey, onChange) => {
const row = document.createElement('div');
row.style.display = 'flex';
row.style.alignItems = 'center';
row.style.justifyContent = 'space-between';
const lbl = document.createElement('label');
lbl.innerText = label;
const chk = document.createElement('input');
chk.type = 'checkbox';
chk.checked = state.config[configKey];
chk.style.cursor = 'pointer';
chk.onchange = (e) => {
state.config[configKey] = e.target.checked;
saveConfig();
if (onChange) onChange(e.target.checked);
};
this.toggles[configKey] = { checkbox: chk, callback: onChange };
row.appendChild(lbl);
row.appendChild(chk);
container.appendChild(row);
};
// Torches Toggle
createToggle('Stage Torches', 'torchesEnabled', (enabled) => {
const torches = sceneFeatureManager.features.find(f => f.constructor.name === 'StageTorches');
if (torches && torches.group) torches.group.visible = enabled;
});
// Lasers Toggle
createToggle('Lasers', 'lasersEnabled');
// Side Screens Toggle
createToggle('Side Screens', 'sideScreensEnabled');
// Console RGB Toggle
createToggle('Console RGB Panel', 'consoleRGBEnabled');
// DJ Hat Selector
const hatRow = document.createElement('div');
hatRow.style.display = 'flex';
hatRow.style.alignItems = 'center';
hatRow.style.justifyContent = 'space-between';
const hatLabel = document.createElement('label');
hatLabel.innerText = 'DJ Hat';
const hatSelect = document.createElement('select');
['None', 'Santa', 'Top Hat'].forEach(opt => {
const option = document.createElement('option');
option.value = opt;
option.innerText = opt;
if (opt === state.config.djHat) option.selected = true;
hatSelect.appendChild(option);
});
hatSelect.onchange = (e) => {
state.config.djHat = e.target.value;
saveConfig();
};
this.hatSelect = hatSelect;
hatRow.appendChild(hatLabel);
hatRow.appendChild(hatSelect);
container.appendChild(hatRow);
// --- Status & Control Section ---
const statusContainer = document.createElement('div');
Object.assign(statusContainer.style, {
marginTop: '15px',
paddingTop: '10px',
borderTop: '1px solid #555',
display: 'flex',
flexDirection: 'column',
gap: '8px'
});
// Loaded Song Label
this.songLabel = document.createElement('div');
this.songLabel.innerText = 'Song: (None)';
this.songLabel.style.fontSize = '13px';
this.songLabel.style.color = '#aaa';
statusContainer.appendChild(this.songLabel);
// Loaded Tapes List
const tapesLabel = document.createElement('div');
tapesLabel.innerText = 'Loaded Tapes:';
tapesLabel.style.fontSize = '13px';
statusContainer.appendChild(tapesLabel);
this.tapeList = document.createElement('ul');
Object.assign(this.tapeList.style, {
margin: '0',
paddingLeft: '20px',
fontSize: '12px',
color: '#aaa',
maxHeight: '100px',
overflowY: 'auto'
});
statusContainer.appendChild(this.tapeList);
// Load Tapes Button
const loadTapesBtn = document.createElement('button');
loadTapesBtn.id = 'loadTapeButton';
loadTapesBtn.innerText = 'Load Tapes';
Object.assign(loadTapesBtn.style, {
marginTop: '10px',
padding: '8px',
cursor: 'pointer',
backgroundColor: '#555',
color: 'white',
border: 'none',
borderRadius: '4px',
fontSize: '14px'
});
statusContainer.appendChild(loadTapesBtn);
state.loadTapeButton = loadTapesBtn;
// Choose Song Button
const chooseSongBtn = document.createElement('button');
chooseSongBtn.innerText = 'Choose Song';
Object.assign(chooseSongBtn.style, {
marginTop: '10px',
padding: '8px',
cursor: 'pointer',
backgroundColor: '#555',
color: 'white',
border: 'none',
borderRadius: '4px',
fontSize: '14px'
});
chooseSongBtn.onclick = () => {
const fileInput = document.getElementById('musicFileInput');
if (fileInput) fileInput.click();
};
statusContainer.appendChild(chooseSongBtn);
// Start Party Button
this.startButton = document.createElement('button');
this.startButton.innerText = 'Start the Party';
this.startButton.disabled = true;
Object.assign(this.startButton.style, {
marginTop: '10px',
padding: '10px',
cursor: 'not-allowed',
backgroundColor: '#333',
color: '#777',
border: 'none',
borderRadius: '4px',
fontSize: '16px',
fontWeight: 'bold'
});
this.startButton.onclick = () => {
const musicPlayer = sceneFeatureManager.features.find(f => f.constructor.name === 'MusicPlayer');
if (musicPlayer) musicPlayer.startSequence();
};
statusContainer.appendChild(this.startButton);
container.appendChild(statusContainer);
// Reset Button
const resetBtn = document.createElement('button');
resetBtn.innerText = 'Reset Defaults';
Object.assign(resetBtn.style, {
marginTop: '10px',
padding: '8px',
cursor: 'pointer',
backgroundColor: '#555',
color: 'white',
border: 'none',
borderRadius: '4px',
fontSize: '14px'
});
resetBtn.onclick = () => {
localStorage.removeItem('partyConfig');
MediaStorage.clear();
// Clear State - Video
if (state.videoUrls) {
state.videoUrls.forEach(url => URL.revokeObjectURL(url));
}
state.videoUrls = [];
state.videoFilenames = [];
state.isVideoLoaded = false;
state.currentVideoIndex = -1;
if (state.loadTapeButton) {
state.loadTapeButton.innerText = 'Load Tapes';
state.loadTapeButton.classList.remove('hidden');
}
// Clear State - Music
if (state.music) {
state.music.songTitle = null;
if (state.music.player) {
state.music.player.pause();
state.music.player.removeAttribute('src');
state.music.player.load();
}
}
showStandbyScreen();
const defaults = {
torchesEnabled: true,
lasersEnabled: true,
sideScreensEnabled: true,
consoleRGBEnabled: true,
djHat: 'None'
};
for (const key in defaults) {
state.config[key] = defaults[key];
if (this.toggles[key]) {
this.toggles[key].checkbox.checked = defaults[key];
if (this.toggles[key].callback) this.toggles[key].callback(defaults[key]);
}
}
if (this.hatSelect) this.hatSelect.value = defaults.djHat;
this.updateStatus();
};
container.appendChild(resetBtn);
document.body.appendChild(container);
this.container = container;
this.updateStatus();
}
updateStatus() {
if (!this.songLabel) return;
// Update Song Info
if (state.music && state.music.songTitle) {
this.songLabel.innerText = `Song: ${state.music.songTitle}`;
this.songLabel.style.color = '#fff';
this.startButton.disabled = false;
this.startButton.style.backgroundColor = '#28a745';
this.startButton.style.color = 'white';
this.startButton.style.cursor = 'pointer';
} else {
this.songLabel.innerText = 'Song: (None)';
this.songLabel.style.color = '#aaa';
this.startButton.disabled = true;
this.startButton.style.backgroundColor = '#333';
this.startButton.style.color = '#777';
this.startButton.style.cursor = 'not-allowed';
}
// Update Tape List
this.tapeList.innerHTML = '';
if (state.videoUrls && state.videoUrls.length > 0) {
state.videoUrls.forEach((url, index) => {
const li = document.createElement('li');
let name = `Tape ${index + 1}`;
if (state.videoFilenames && state.videoFilenames[index]) {
name = state.videoFilenames[index];
}
if (name.length > 25) {
li.innerText = name.substring(0, 22) + '...';
li.title = name;
} else {
li.innerText = name;
}
this.tapeList.appendChild(li);
});
} else {
const li = document.createElement('li');
li.innerText = '(No tapes loaded)';
this.tapeList.appendChild(li);
}
}
onPartyStart() {
if (this.container) this.container.style.display = 'none';
}
onPartyEnd() {
if (this.container) this.container.style.display = 'flex';
}
}
+34
View File
@@ -113,6 +113,35 @@ export class DJ extends SceneFeature {
this.currentRightAngle = Math.PI * 0.85;
this.currentLeftAngleX = 0;
this.currentRightAngleX = 0;
// --- Hats ---
this.hats = {};
// Santa Hat
const santaGroup = new THREE.Group();
const santaCone = new THREE.Mesh(new THREE.ConeGeometry(0.14, 0.3, 16), new THREE.MeshStandardMaterial({ color: 0xff0000 }));
santaCone.position.y = 0.15;
santaCone.rotation.x = -0.2;
const santaRim = new THREE.Mesh(new THREE.TorusGeometry(0.14, 0.04, 8, 16), new THREE.MeshStandardMaterial({ color: 0xffffff }));
santaRim.rotation.x = Math.PI/2;
const santaBall = new THREE.Mesh(new THREE.SphereGeometry(0.04), new THREE.MeshStandardMaterial({ color: 0xffffff }));
santaBall.position.set(0, 0.28, -0.08);
santaGroup.add(santaCone, santaRim, santaBall);
santaGroup.position.set(0, 0.22, 0);
santaGroup.visible = false;
this.head.add(santaGroup);
this.hats['Santa'] = santaGroup;
// Top Hat
const topHatGroup = new THREE.Group();
const brim = new THREE.Mesh(new THREE.CylinderGeometry(0.25, 0.25, 0.02, 16), new THREE.MeshStandardMaterial({ color: 0x111111 }));
const cylinder = new THREE.Mesh(new THREE.CylinderGeometry(0.15, 0.15, 0.25, 16), new THREE.MeshStandardMaterial({ color: 0x111111 }));
cylinder.position.y = 0.125;
topHatGroup.add(brim, cylinder);
topHatGroup.position.set(0, 0.22, 0);
topHatGroup.visible = false;
this.head.add(topHatGroup);
this.hats['Top Hat'] = topHatGroup;
}
update(deltaTime) {
@@ -198,6 +227,11 @@ export class DJ extends SceneFeature {
this.group.position.x += dir * speed * deltaTime;
}
}
// Update Hat Visibility
for (const [name, mesh] of Object.entries(this.hats)) {
mesh.visible = (state.config.djHat === name);
}
}
onPartyStart() {
+5
View File
@@ -205,6 +205,11 @@ export class MusicConsole extends SceneFeature {
// Update Front LED Array
if (this.frontLedMesh) {
if (!state.config.consoleRGBEnabled) {
this.frontLedMesh.visible = false;
return;
}
this.frontLedMesh.visible = true;
const color = new THREE.Color();
let idx = 0;
+57 -42
View File
@@ -2,6 +2,7 @@ import { state } from '../state.js';
import { SceneFeature } from './SceneFeature.js';
import sceneFeatureManager from './SceneFeatureManager.js';
import { showStandbyScreen } from './projection-screen.js';
import { MediaStorage } from '../core/media-storage.js';
export class MusicPlayer extends SceneFeature {
constructor() {
@@ -21,54 +22,73 @@ export class MusicPlayer extends SceneFeature {
const loadButton = document.getElementById('loadMusicButton');
const fileInput = document.getElementById('musicFileInput');
const uiContainer = document.getElementById('ui-container');
const metadataContainer = document.getElementById('metadata-container');
const songTitleElement = document.getElementById('song-title');
loadButton.addEventListener('click', () => {
fileInput.click();
});
// Hide the big start button as we use ConfigUI now
if (loadButton) loadButton.style.display = 'none';
fileInput.addEventListener('change', (event) => {
const file = event.target.files[0];
if (file) {
// Setup Web Audio API if not already done
if (!this.audioContext) {
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
this.analyser = this.audioContext.createAnalyser();
this.analyser.fftSize = 128; // Lower resolution is fine for loudness
this.source = this.audioContext.createMediaElementSource(state.music.player);
this.source.connect(this.analyser);
this.analyser.connect(this.audioContext.destination);
this.dataArray = new Uint8Array(this.analyser.frequencyBinCount);
}
// Hide the main button
loadButton.style.display = 'none';
// Show metadata
const songName = file.name.replace(/\.[^/.]+$/, "");
songTitleElement.textContent = songName; // Show filename without extension
state.music.songTitle = songName;
showStandbyScreen();
metadataContainer.classList.add('hidden');
const url = URL.createObjectURL(file);
state.music.player.src = url;
// Wait 5 seconds, then start the party
setTimeout(() => {
metadataContainer.classList.add('hidden');
this.startParty();
}, 5000);
this.loadMusicFile(file);
MediaStorage.saveMusic(file);
}
});
state.music.player.addEventListener('ended', () => {
this.stopParty();
uiContainer.style.display = 'flex'; // Show the button again
const uiContainer = document.getElementById('ui-container');
if (uiContainer) uiContainer.style.display = 'flex'; // Show the button again
});
// Restore from storage
MediaStorage.getMusic().then(file => {
if (file) {
this.loadMusicFile(file);
}
});
}
loadMusicFile(file) {
// Setup Web Audio API if not already done
if (!this.audioContext) {
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
this.analyser = this.audioContext.createAnalyser();
this.analyser.fftSize = 128; // Lower resolution is fine for loudness
this.source = this.audioContext.createMediaElementSource(state.music.player);
this.source.connect(this.analyser);
this.analyser.connect(this.audioContext.destination);
this.dataArray = new Uint8Array(this.analyser.frequencyBinCount);
}
// Update State
const songName = file.name.replace(/\.[^/.]+$/, "");
state.music.songTitle = songName;
const url = URL.createObjectURL(file);
state.music.player.src = url;
// Update Config UI
const configUI = sceneFeatureManager.features.find(f => f.constructor.name === 'ConfigUI');
if (configUI) configUI.updateStatus();
// Update Projection Screen with new song title
showStandbyScreen();
}
startSequence() {
const uiContainer = document.getElementById('ui-container');
const configUI = document.getElementById('config-ui');
if (uiContainer) uiContainer.style.display = 'none';
if (configUI) configUI.style.display = 'none';
if (state.loadTapeButton) state.loadTapeButton.classList.add('hidden');
showStandbyScreen();
// Wait 5 seconds, then start the party
setTimeout(() => {
this.startParty();
}, 5000);
}
startParty() {
@@ -87,11 +107,6 @@ export class MusicPlayer extends SceneFeature {
stopParty() {
state.clock.stop();
state.partyStarted = false;
setTimeout(() => {
const startButton = document.getElementById('loadMusicButton');
startButton.style.display = 'block';
startButton.textContent = "Party some more?"
}, 5000);
// Trigger 'end' event for other features
this.notifyFeatures('onPartyEnd');
}
+9 -2
View File
@@ -174,7 +174,6 @@ export class ProjectionScreen extends SceneFeature {
createSideScreen(11, 4.0, -15.5, -0.4, 100); // Right (Lower resolution)
state.tvScreen = this.mesh;
this.setAllVisible(false);
// --- Screen Light ---
// A light that projects the screen's color/ambiance into the room
@@ -184,10 +183,18 @@ export class ProjectionScreen extends SceneFeature {
state.screenLight.shadow.mapSize.width = 512;
state.screenLight.shadow.mapSize.height = 512;
state.scene.add(state.screenLight);
showStandbyScreen();
}
setAllVisible(visible) {
this.screens.forEach(s => s.mesh.visible = visible);
this.screens.forEach((s, index) => {
if (index === 0) {
s.mesh.visible = visible;
} else {
s.mesh.visible = visible && state.config.sideScreensEnabled;
}
});
}
applyMaterialToAll(baseMaterial) {
+2
View File
@@ -18,10 +18,12 @@ import { MusicConsole } from './music-console.js';
import { DJ } from './dj.js';
import { ProjectionScreen } from './projection-screen.js';
import { StageLasers } from './stage-lasers.js';
import { ConfigUI } from './config-ui.js';
// Scene Features ^^^
// --- Scene Modeling Function ---
export function createSceneObjects() {
new ConfigUI();
sceneFeatureManager.init();
initVideoUI();
+6
View File
@@ -107,6 +107,12 @@ export class StageLasers extends SceneFeature {
const time = state.clock.getElapsedTime();
if (!state.config.lasersEnabled) {
this.lasers.forEach(l => l.mesh.visible = false);
if (state.laserData) state.laserData.count = 0;
return;
}
// --- Loudness Check ---
let isActive = false;
if (state.music) {