diff --git a/party-stage/src/core/video-player.js b/party-stage/src/core/video-player.js index f2fcff0..a407dbe 100644 --- a/party-stage/src/core/video-player.js +++ b/party-stage/src/core/video-player.js @@ -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) { diff --git a/party-stage/src/scene/projection-screen.js b/party-stage/src/scene/projection-screen.js new file mode 100644 index 0000000..5818bf2 --- /dev/null +++ b/party-stage/src/scene/projection-screen.js @@ -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(); diff --git a/party-stage/src/scene/root.js b/party-stage/src/scene/root.js index 6d053cf..30bbc83 100644 --- a/party-stage/src/scene/root.js +++ b/party-stage/src/scene/root.js @@ -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) --- diff --git a/party-stage/src/scene/wall-curtain.js b/party-stage/src/scene/wall-curtain.js index b0cd1bb..10863f0 100644 --- a/party-stage/src/scene/wall-curtain.js +++ b/party-stage/src/scene/wall-curtain.js @@ -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); diff --git a/party-stage/textures/tapestry.png b/party-stage/textures/tapestry.png index 245f5ab..601e729 100644 Binary files a/party-stage/textures/tapestry.png and b/party-stage/textures/tapestry.png differ