Feature: projection screen playing videos or music visualizer
This commit is contained in:
parent
5d3a05ec69
commit
c98d4890eb
@ -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) {
|
||||
state.baseTime += state.videoElement.duration;
|
||||
}
|
||||
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) {
|
||||
|
||||
266
party-stage/src/scene/projection-screen.js
Normal file
266
party-stage/src/scene/projection-screen.js
Normal 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();
|
||||
@ -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) ---
|
||||
|
||||
|
||||
@ -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 |
Loading…
Reference in New Issue
Block a user