import * as THREE from 'three'; /** * Abstract class for content */ export class Content { constructor(data) { this.data = data; this.displayMesh = null; this.texture = null; } /** * @returns {THREE.Mesh} */ createDisplayMesh() { throw new Error("Method 'createDisplayMesh()' must be implemented"); } update() { } dispose() { if (this.displayMesh) { this.displayMesh.geometry.dispose(); this.displayMesh.material.dispose(); } if (this.texture) this.texture.dispose(); } } export class TextContent extends Content { constructor(data) { super(data); this.canvas = null; this.context = null; this.lines = []; this.lineHeight = 80; // Font size + padding this.maxLinesVisible = 0; this.scrollOffset = 0; // Index of the first line to display this.color = 'black'; } createDisplayMesh() { this.canvas = document.createElement('canvas'); const canvasSize = 1024; this.canvas.width = 2048; this.canvas.height = canvasSize; this.context = this.canvas.getContext('2d'); document.fonts.load('60px Redacted70').then(() => { this._wrapText(); }); this.texture = new THREE.CanvasTexture(this.canvas); const material = new THREE.MeshBasicMaterial({ map: this.texture, transparent: true }); const geometry = new THREE.PlaneGeometry(10, 5); this.displayMesh = new THREE.Mesh(geometry, material); document.fonts.load('100px Redacted70').then(() => { this._redrawCanvas(); }); return this.displayMesh; } _wrapText() { const context = this.context; const canvasWidth = this.canvas.width; const margin = 20; const maxWidth = canvasWidth - (margin * 2); context.font = '100px Redacted70'; const words = this.data.split(' '); let line = ''; this.lines = []; for (let n = 0; n < words.length; n++) { const testLine = line + words[n] + ' '; const metrics = context.measureText(testLine); const testWidth = metrics.width; if (testWidth > maxWidth && n > 0) { this.lines.push(line); line = words[n] + ' '; } else { line = testLine; } } this.lines.push(line); this.maxLinesVisible = Math.floor(this.canvas.height / this.lineHeight) - 1; // Leave space for scroll indicators } _redrawCanvas() { const context = this.context; const canvasWidth = this.canvas.width; const canvasHeight = this.canvas.height; const margin = 20; context.clearRect(0, 0, canvasWidth, canvasHeight); // Background const gradient = context.createLinearGradient(0, 0, 0, canvasHeight); gradient.addColorStop(0, 'rgba(255.0,255.0,255.0,0.5)'); gradient.addColorStop(1, 'rgba(255.0,255.0,255.0,0.3)'); context.fillStyle = gradient; context.fillRect(0, 0, canvasWidth, canvasHeight); // Text context.fillStyle = this.color; context.font = '100px Redacted70'; for (let i = 0; i < this.maxLinesVisible; i++) { const lineIndex = this.scrollOffset + i; if (lineIndex < this.lines.length) { const y = (i + 1) * this.lineHeight; context.fillText(this.lines[lineIndex], margin, y); } } // Scroll indicators if (this.scrollOffset > 0) { context.fillText('#', canvasWidth - margin - 40, this.lineHeight); } if (this.scrollOffset + this.maxLinesVisible < this.lines.length) { context.fillText('@', canvasWidth - margin - 40, canvasHeight - margin); } this.texture.needsUpdate = true; } scrollUp() { if (this.scrollOffset > 0) { this.scrollOffset--; this._redrawCanvas(); } } scrollDown() { if (this.scrollOffset + this.maxLinesVisible < this.lines.length) { this.scrollOffset++; this._redrawCanvas(); } } dispose() { super.dispose(); this.canvas = null; this.context = null; } } export class ImageContent extends Content { constructor(data) { super(data); // data should be the image URL or base64 string this.image = null; } createDisplayMesh() { return new Promise((resolve, reject) => { const loader = new THREE.TextureLoader(); loader.load( this.data, (texture) => { this.texture = texture; const aspect = texture.image.width / texture.image.height; const height = 5; const width = height * aspect; const geometry = new THREE.PlaneGeometry(width, height); const material = new THREE.MeshBasicMaterial({ map: texture, transparent: true }); this.displayMesh = new THREE.Mesh(geometry, material); resolve(this.displayMesh); }, undefined, (err) => { reject(err); } ); }); } dispose() { super.dispose(); this.image = null; } } export class VideoContent extends Content { constructor(data) { super(data); this.video = null; } createDisplayMesh() { this.video = document.createElement('video'); this.video.src = this.data; this.video.crossOrigin = 'anonymous'; this.video.loop = true; this.video.muted = false; this.video.playsInline = true; this.video.autoplay = true; // Return a promise so you can await mesh creation return new Promise((resolve, reject) => { this.video.addEventListener('loadedmetadata', () => { const aspect = this.video.videoWidth / this.video.videoHeight; const height = 5; // or any desired height const width = height * aspect; this.texture = new THREE.VideoTexture(this.video); this.texture.minFilter = THREE.LinearFilter; this.texture.magFilter = THREE.LinearFilter; this.texture.format = THREE.RGBAFormat; const geometry = new THREE.PlaneGeometry(width, height); const material = new THREE.MeshBasicMaterial({ map: this.texture, side: THREE.DoubleSide }); this.displayMesh = new THREE.Mesh(geometry, material); this.video.play(); resolve(this.displayMesh); }); this.video.addEventListener('error', (e) => { reject(e); }); }); } dispose() { super.dispose(); if (this.video) { this.video.pause(); this.video.src = ''; this.video.load(); this.video = null; } } } export class AudioContent extends Content { constructor(data, audioListener) { super(data); // data should be the audio URL or base64 string this.audioListener = audioListener; // Pass in a THREE.AudioListener instance this.audio = null; this.isLoaded = false; this.volume = 0.1; this.isFadingOut = false; this.fadeInfo = null; this.itemContentDisplay = null; this.object = null; } createDisplayMesh() { return new Promise((resolve, reject) => { const geometry = new THREE.SphereGeometry(1, 4, 4); const color = new THREE.Color(0, 0, 0, 1); const material = new THREE.MeshBasicMaterial({ color: color, wireframe: true }); this.displayMesh = new THREE.Mesh(geometry, material); this.audio = new THREE.PositionalAudio(this.audioListener); const loader = new THREE.AudioLoader(); loader.load( this.data, (buffer) => { this.audio.setBuffer(buffer); this.audio.setLoop(true); this.audio.setVolume(this.volume); this.isLoaded = true; this.displayMesh.add(this.audio); this.audio.play(); resolve(this.displayMesh); // Only resolve after audio is loaded }, undefined, (err) => { console.error('Audio load error:', err); reject(err); } ); }); } play() { if (this.isLoaded && this.audio && !this.audio.isPlaying) { this.audio.play(); } } /** * Starts a gradual fade-out of the audio. The update method will handle disposal. * @param {number} duration - The duration of the fade in milliseconds. */ fadeOutAndDispose(duration = 2000, obj, itemContentDisplay) { // Default to 2 seconds this.object = obj; this.itemContentDisplay = itemContentDisplay; if (this.audio && this.audio.isPlaying && !this.isFadingOut) { this.isFadingOut = true; this.fadeInfo = { startTime: performance.now(), duration: duration, initialVolume: this.audio.getVolume() }; } else if (!this.audio || !this.audio.isPlaying) { // If not playing, dispose immediately this.dispose(); } } update() { if (!this.isFadingOut || !this.audio) { return; } const elapsedTime = performance.now() - this.fadeInfo.startTime; if (elapsedTime >= this.fadeInfo.duration) { // Fade complete this.audio.setVolume(0); this.isFadingOut = false; this.dispose(); } else { // In progress, calculate new volume const fadeProgress = elapsedTime / this.fadeInfo.duration; const newVolume = this.fadeInfo.initialVolume * (1 - fadeProgress); this.audio.setVolume(newVolume); } } dispose() { super.dispose(); if (this.audio) { this.object.remove(this.itemContentDisplay); this.itemContentDisplay = null; this.audio.stop(); this.audio.disconnect(); this.object = null; this.audio = null; this.isLoaded = false; } } }