Feature: projection screen playing videos or music visualizer

This commit is contained in:
Dejvino 2025-12-30 17:37:22 +00:00
parent 5d3a05ec69
commit c98d4890eb
5 changed files with 345 additions and 13 deletions

View File

@ -1,6 +1,6 @@
import * as THREE from 'three';
import { state } from '../state.js';
import { turnTvScreenOff, turnTvScreenOn } from '../scene/magic-mirror.js';
import { turnTvScreenOff, turnTvScreenOn } from '../scene/projection-screen.js';
// --- Play video by index ---
export function playVideoByIndex(index) {
@ -13,12 +13,9 @@ export function playVideoByIndex(index) {
state.videoTexture = null;
}
if (index < 0 || index >= state.videoUrls.length) {
console.info('End of playlist reached. Reload tapes to start again.');
turnTvScreenOff();
state.isVideoLoaded = false;
state.lastUpdateTime = -1; // force VCR to redraw
return;
// Loop logic: if index is out of bounds, wrap around
if (index >= state.videoUrls.length) {
index = 0;
}
state.videoElement.src = url;
@ -28,6 +25,9 @@ export function playVideoByIndex(index) {
// Set loop property: only loop if it's the only video loaded
state.videoElement.loop = false; //state.videoUrls.length === 1;
// Auto-play next video when this one ends
state.videoElement.onended = () => playNextVideo();
state.videoElement.onloadeddata = () => {
// 1. Create the Three.js texture
@ -47,6 +47,7 @@ export function playVideoByIndex(index) {
state.screenLight.intensity = state.originalScreenIntensity;
// Initial status message with tape count
console.info(`Playing tape ${state.currentVideoIndex + 1} of ${state.videoUrls.length}.`);
updatePlayPauseButton();
}).catch(error => {
state.screenLight.intensity = state.originalScreenIntensity * 0.5; // Dim the light if playback fails
console.error(`Playback blocked for tape ${state.currentVideoIndex + 1}. Click Next Tape to try again.`);
@ -64,13 +65,75 @@ export function playVideoByIndex(index) {
// --- Cycle to the next video ---
export function playNextVideo() {
// Determine the next index, cycling back to 0 if we reach the end
let nextIndex = state.currentVideoIndex + 1;
if (nextIndex < state.videoUrls.length) {
let nextIndex = (state.currentVideoIndex + 1) % state.videoUrls.length;
state.baseTime += state.videoElement.duration;
}
playVideoByIndex(nextIndex);
}
export function playPreviousVideo() {
let prevIndex = state.currentVideoIndex - 1;
if (prevIndex < 0) {
prevIndex = state.videoUrls.length - 1;
}
playVideoByIndex(prevIndex);
}
export function togglePlayPause() {
if (!state.videoElement) return;
if (state.videoElement.paused) {
state.videoElement.play();
} else {
state.videoElement.pause();
}
updatePlayPauseButton();
}
function updatePlayPauseButton() {
const btn = document.getElementById('video-play-pause-btn');
if (btn) {
btn.innerText = (state.videoElement && !state.videoElement.paused) ? 'Pause' : 'Play';
}
}
export function initVideoUI() {
// 1. File Input
if (!state.fileInput) {
const input = document.createElement('input');
input.type = 'file';
input.id = 'fileInput';
input.multiple = true;
input.accept = 'video/*';
input.style.display = 'none';
document.body.appendChild(input);
state.fileInput = input;
}
state.fileInput.onchange = loadVideoFile;
// 2. Load Button
if (!state.loadTapeButton) {
const btn = document.createElement('button');
btn.id = 'loadTapeButton';
btn.innerText = 'Load Tapes';
Object.assign(btn.style, {
position: 'absolute',
top: '20px',
left: '20px',
zIndex: '1000',
padding: '10px 20px',
fontSize: '16px',
cursor: 'pointer',
background: '#fff',
color: '#000',
border: 'none',
borderRadius: '5px',
fontFamily: 'sans-serif'
});
document.body.appendChild(btn);
state.loadTapeButton = btn;
}
state.loadTapeButton.onclick = () => state.fileInput.click();
}
// --- Video Loading Logic (handles multiple files) ---
export function loadVideoFile(event) {

View File

@ -0,0 +1,266 @@
import * as THREE from 'three';
import { state } from '../state.js';
import { SceneFeature } from './SceneFeature.js';
import sceneFeatureManager from './SceneFeatureManager.js';
// --- Shaders for Screen Effects ---
const screenVertexShader = `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
const screenFragmentShader = `
uniform sampler2D videoTexture;
uniform float u_effect_type;
uniform float u_effect_strength;
uniform float u_time;
varying vec2 vUv;
float random(vec2 st) {
return fract(sin(dot(st.xy, vec2(12.9898,78.233))) * 43758.5453123);
}
void main() {
vec2 uv = vUv;
vec4 color = texture2D(videoTexture, uv);
// Effect 1: Static/Noise (Power On/Off)
if (u_effect_type > 0.0) {
float noise = random(uv + u_time);
vec3 noiseColor = vec3(noise);
color.rgb = mix(color.rgb, noiseColor, u_effect_strength);
}
gl_FragColor = color;
}
`;
const visualizerFragmentShader = `
uniform float u_time;
uniform float u_beat;
varying vec2 vUv;
vec3 hsv2rgb(vec3 c) {
vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0);
vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);
return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);
}
void main() {
vec2 uv = vUv;
float dist = length(uv - 0.5);
float angle = atan(uv.y - 0.5, uv.x - 0.5);
float wave = sin(dist * 20.0 - u_time * 2.0);
float beatWave = sin(angle * 5.0 + u_time) * u_beat;
float hue = fract(u_time * 0.1 + dist * 0.2);
float val = 0.5 + 0.5 * sin(wave + beatWave);
gl_FragColor = vec4(hsv2rgb(vec3(hue, 0.8, val)), 1.0);
}
`;
let projectionScreenInstance = null;
export class ProjectionScreen extends SceneFeature {
constructor() {
super();
projectionScreenInstance = this;
this.isVisualizerActive = false;
sceneFeatureManager.register(this);
}
init() {
// --- Initialize State ---
state.tvScreenPowered = false;
state.screenEffect = {
active: false,
type: 0,
startTime: 0,
duration: 1000,
easing: (t) => t,
onComplete: null
};
state.originalScreenIntensity = 2.0;
// Ensure video element exists
if (!state.videoElement) {
state.videoElement = document.createElement('video');
state.videoElement.crossOrigin = 'anonymous';
state.videoElement.playsInline = true;
state.videoElement.style.display = 'none';
document.body.appendChild(state.videoElement);
}
// --- Create Screen Mesh ---
// 16:9 Aspect Ratio, large size
const width = 10;
const height = width * (9 / 16);
const geometry = new THREE.PlaneGeometry(width, height);
// Initial black material
const material = new THREE.MeshBasicMaterial({ color: 0x000000 });
this.mesh = new THREE.Mesh(geometry, material);
// High enough to be seen
this.mesh.position.set(0, 5.5, -20.5);
state.scene.add(this.mesh);
state.tvScreen = this.mesh;
// --- Screen Light ---
// A light that projects the screen's color/ambiance into the room
state.screenLight = new THREE.PointLight(0xffffff, 0, 25);
state.screenLight.position.set(0, 5.5, -18);
state.screenLight.castShadow = true;
state.screenLight.shadow.mapSize.width = 512;
state.screenLight.shadow.mapSize.height = 512;
state.scene.add(state.screenLight);
}
update(deltaTime) {
updateScreenEffect();
if (this.isVisualizerActive && state.tvScreen.material.uniforms) {
state.tvScreen.material.uniforms.u_time.value = state.clock.getElapsedTime();
const beat = (state.music && state.music.beatIntensity) ? state.music.beatIntensity : 0.0;
state.tvScreen.material.uniforms.u_beat.value = beat;
// Sync light to beat
state.screenLight.intensity = state.originalScreenIntensity * (0.5 + beat * 0.5);
}
}
onPartyStart() {
// Hide load button during playback
if (state.loadTapeButton) state.loadTapeButton.classList.add('hidden');
// If no video loaded, start visualizer
if (!state.isVideoLoaded) {
this.activateVisualizer();
}
}
onPartyEnd() {
// Show load button if no video is loaded
if (state.loadTapeButton && !state.isVideoLoaded) state.loadTapeButton.classList.remove('hidden');
if (this.isVisualizerActive) {
this.deactivateVisualizer();
}
}
activateVisualizer() {
this.isVisualizerActive = true;
state.tvScreen.visible = true;
state.tvScreenPowered = true;
state.tvScreen.material = new THREE.ShaderMaterial({
uniforms: {
u_time: { value: 0.0 },
u_beat: { value: 0.0 }
},
vertexShader: screenVertexShader,
fragmentShader: visualizerFragmentShader,
side: THREE.DoubleSide
});
state.screenLight.intensity = state.originalScreenIntensity;
}
deactivateVisualizer() {
this.isVisualizerActive = false;
turnTvScreenOff();
}
}
// --- Exported Control Functions ---
export function turnTvScreenOn() {
if (projectionScreenInstance) projectionScreenInstance.isVisualizerActive = false;
if (state.tvScreen.material) {
state.tvScreen.material.dispose();
}
state.tvScreen.visible = true;
// Switch to ShaderMaterial for video playback
state.tvScreen.material = new THREE.ShaderMaterial({
uniforms: {
videoTexture: { value: state.videoTexture },
u_effect_type: { value: 0.0 },
u_effect_strength: { value: 0.0 },
u_time: { value: 0.0 },
},
vertexShader: screenVertexShader,
fragmentShader: screenFragmentShader,
side: THREE.DoubleSide
});
state.tvScreen.material.needsUpdate = true;
if (!state.tvScreenPowered) {
state.tvScreenPowered = true;
setScreenEffect(1); // Trigger power on static effect
}
}
export function turnTvScreenOff() {
if (state.tvScreenPowered) {
state.tvScreenPowered = false;
setScreenEffect(2, () => {
// Revert to black material or hide
state.tvScreen.material = new THREE.MeshBasicMaterial({ color: 0x000000 });
state.screenLight.intensity = 0.0;
});
}
}
export function setScreenEffect(effectType, onComplete) {
const material = state.tvScreen.material;
if (!material.uniforms) return;
state.screenEffect.active = true;
state.screenEffect.type = effectType;
state.screenEffect.startTime = state.clock.getElapsedTime() * 1000;
state.screenEffect.onComplete = onComplete;
}
export function updateScreenEffect() {
if (!state.screenEffect || !state.screenEffect.active) return;
const material = state.tvScreen.material;
if (!material || !material.uniforms) return;
// Update time uniform for noise
material.uniforms.u_time.value = state.clock.getElapsedTime();
const elapsedTime = (state.clock.getElapsedTime() * 1000) - state.screenEffect.startTime;
const progress = Math.min(elapsedTime / state.screenEffect.duration, 1.0);
// Simple linear fade for effect strength
// Type 1 (On): 1.0 -> 0.0
// Type 2 (Off): 0.0 -> 1.0
let strength = progress;
if (state.screenEffect.type === 1) {
strength = 1.0 - progress;
}
material.uniforms.u_effect_type.value = state.screenEffect.type;
material.uniforms.u_effect_strength.value = strength;
if (progress >= 1.0) {
state.screenEffect.active = false;
if (state.screenEffect.onComplete) {
state.screenEffect.onComplete();
}
// Reset effect uniforms
material.uniforms.u_effect_type.value = 0.0;
material.uniforms.u_effect_strength.value = 0.0;
}
}
new ProjectionScreen();

View File

@ -2,6 +2,7 @@ import * as THREE from 'three';
import { state } from '../state.js';
import floorTextureUrl from '/textures/floor.png';
import sceneFeatureManager from './SceneFeatureManager.js';
import { initVideoUI } from '../core/video-player.js';
// Scene Features registered here:
import { CameraManager } from './camera-manager.js';
import { LightBall } from './light-ball.js';
@ -15,11 +16,13 @@ import { ReproWall } from './repro-wall.js';
import { StageLights } from './stage-lights.js';
import { MusicConsole } from './music-console.js';
import { DJ } from './dj.js';
import { ProjectionScreen } from './projection-screen.js';
// Scene Features ^^^
// --- Scene Modeling Function ---
export function createSceneObjects() {
sceneFeatureManager.init();
initVideoUI();
// --- Materials (MeshPhongMaterial) ---

View File

@ -15,7 +15,7 @@ export class WallCurtain extends SceneFeature {
init() {
// --- Curtain Properties ---
const naveWidth = 12;
const naveHeight = 7;
const naveHeight = 10;
const stageHeight = 1.5;
const curtainWidth = naveWidth; // Span the width of the nave
const curtainHeight = naveHeight - stageHeight; // Hang from the ceiling down to the stage
@ -54,7 +54,7 @@ export class WallCurtain extends SceneFeature {
};
// Place a single large curtain behind the stage
const backWallZ = -20;
const backWallZ = -21;
const curtainY = stageHeight + curtainHeight / 2;
const curtainPosition = new THREE.Vector3(0, curtainY, backWallZ + 0.1);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB