You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
347 lines
11 KiB
347 lines
11 KiB
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;
|
|
}
|
|
}
|
|
}
|