From 774e42e38750541fc018f7f0251ab66b3f9ba201 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Sun, 13 Jul 2025 23:53:21 +0200 Subject: [PATCH] initial commit --- package.json | 26 ++ src/index.html | 36 ++ src/index.js | 470 +++++++++++++++++++++++++ src/styles.css | 310 ++++++++++++++++ src/themes/circleShaderTheme.js | 253 +++++++++++++ src/themes/circularBlobsShaderTheme.js | 270 ++++++++++++++ src/themes/rainbowShaderTheme.js | 275 +++++++++++++++ src/themes/shaderTheme.js | 319 +++++++++++++++++ src/themes/textTheme.js | 89 +++++ webpack.config.js | 52 +++ 10 files changed, 2100 insertions(+) create mode 100644 package.json create mode 100644 src/index.html create mode 100644 src/index.js create mode 100644 src/styles.css create mode 100644 src/themes/circleShaderTheme.js create mode 100644 src/themes/circularBlobsShaderTheme.js create mode 100644 src/themes/rainbowShaderTheme.js create mode 100644 src/themes/shaderTheme.js create mode 100644 src/themes/textTheme.js create mode 100644 webpack.config.js diff --git a/package.json b/package.json new file mode 100644 index 0000000..8168921 --- /dev/null +++ b/package.json @@ -0,0 +1,26 @@ +{ + "name": "timer", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "start": "webpack serve", + "build": "webpack --mode production", + "dev": "webpack --mode development" + }, + "keywords": [], + "author": "", + "license": "ISC", + "devDependencies": { + "@babel/core": "^7.28.0", + "@babel/preset-env": "^7.28.0", + "babel-loader": "^10.0.0", + "copy-webpack-plugin": "^13.0.0", + "css-loader": "^7.1.2", + "html-webpack-plugin": "^5.6.3", + "style-loader": "^4.0.0", + "webpack": "^5.100.1", + "webpack-cli": "^6.0.1", + "webpack-dev-server": "^5.2.2" + } +} diff --git a/src/index.html b/src/index.html new file mode 100644 index 0000000..54ceb9a --- /dev/null +++ b/src/index.html @@ -0,0 +1,36 @@ + + + + + + Timer App + + + +
+

Timer

+ +
+ + +
+ +
+ + + + + + + + + + +
+ +
+ +
+
+ + \ No newline at end of file diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..257e8a6 --- /dev/null +++ b/src/index.js @@ -0,0 +1,470 @@ +// CSS is loaded via link tag in HTML +import { TextTheme } from './themes/textTheme'; +import { CircleShaderTheme } from './themes/circleShaderTheme'; +import { RainbowShaderTheme } from './themes/rainbowShaderTheme'; +import { CircularBlobsShaderTheme } from './themes/circularBlobsShaderTheme'; +import { ShaderTheme } from './themes/shaderTheme'; + +class TimerApp { + constructor() { + this.themes = { + shader: { + class: ShaderTheme, + name: 'Plasma Flow', + description: 'Dynamic WebGL shader with rings', + popularity: 0 + }, + circularBlobs: { + class: CircularBlobsShaderTheme, + name: 'Orbital Dots', + description: 'Dots consumed in circular motion', + popularity: 0 + }, + circle: { + class: CircleShaderTheme, + name: 'Arc Progress', + description: 'Classic circular progress indicator', + popularity: 0 + }, + rainbow: { + class: RainbowShaderTheme, + name: 'Rainbow Orbits', + description: 'Colorful rotating circles', + popularity: 0 + }, + text: { + class: TextTheme, + name: 'Digital Clock', + description: 'Simple text countdown', + popularity: 0 + } + }; + + this.app = document.getElementById('app'); + this.setupContent = null; // Will store the setup screen content + + this.currentTheme = null; + this.selectedThemeId = 'shader'; + this.selectedDuration = { minutes: 5, seconds: 0 }; + this.animationId = null; + this.startTime = null; + this.duration = null; + + // Time in milliseconds for completion animation to reach resting state + this.completionDuration = 2400; + + // Load saved data + this.loadSavedData(); + + // Initialize UI + this.initializeThemeGallery(); + this.initializeEventListeners(); + + // Canvas will be created when timer starts + this.canvas = null; + this.ctx = null; + + // Expose to window for debugging + window.timerApp = { + setCompletionDuration: (ms) => { + this.completionDuration = Math.max(100, Math.min(10000, ms)); + console.log(`Completion animation duration set to ${this.completionDuration}ms`); + }, + getCompletionDuration: () => this.completionDuration + }; + } + + loadSavedData() { + // Load theme popularity + const savedPopularity = localStorage.getItem('themePopularity'); + if (savedPopularity) { + const popularity = JSON.parse(savedPopularity); + Object.keys(popularity).forEach(themeId => { + if (this.themes[themeId]) { + this.themes[themeId].popularity = popularity[themeId]; + } + }); + } + + + // Load last used theme + const lastTheme = localStorage.getItem('lastSelectedTheme'); + if (lastTheme && this.themes[lastTheme]) { + this.selectedThemeId = lastTheme; + } + } + + saveThemePopularity() { + const popularity = {}; + Object.keys(this.themes).forEach(themeId => { + popularity[themeId] = this.themes[themeId].popularity; + }); + localStorage.setItem('themePopularity', JSON.stringify(popularity)); + } + + initializeThemeGallery() { + const themeGrid = document.querySelector('.theme-grid'); + themeGrid.innerHTML = ''; + + // Sort themes by popularity + const sortedThemes = Object.entries(this.themes).sort((a, b) => + b[1].popularity - a[1].popularity + ); + + sortedThemes.forEach(([themeId, theme]) => { + const card = this.createThemeCard(themeId, theme); + themeGrid.appendChild(card); + }); + + // Select the initial theme + this.selectTheme(this.selectedThemeId); + } + + createThemeCard(themeId, theme) { + const card = document.createElement('div'); + card.className = 'theme-card'; + card.dataset.themeId = themeId; + + // Preview canvas + const preview = document.createElement('div'); + preview.className = 'theme-preview'; + const previewCanvas = document.createElement('canvas'); + previewCanvas.width = 200; + previewCanvas.height = 120; + preview.appendChild(previewCanvas); + + // Theme info + const info = document.createElement('div'); + info.className = 'theme-info'; + + const name = document.createElement('div'); + name.className = 'theme-name'; + name.textContent = theme.name; + + const description = document.createElement('div'); + description.className = 'theme-description'; + description.textContent = theme.description; + + info.appendChild(name); + info.appendChild(description); + + // Popularity badge + if (theme.popularity > 0) { + const badge = document.createElement('div'); + badge.className = 'popularity-badge'; + badge.innerHTML = `⭐ ${theme.popularity}`; + card.appendChild(badge); + } + + card.appendChild(preview); + card.appendChild(info); + + // Initialize mini theme preview AFTER card is built + this.initializeThemePreview(themeId, theme, previewCanvas, card); + + // Click handler + card.addEventListener('click', () => { + this.selectTheme(themeId); + }); + + return card; + } + + initializeThemePreview(themeId, theme, canvas, card) { + const ctx = canvas.getContext('2d'); + const miniTheme = new theme.class(canvas, ctx); + + // Set completion duration for preview + if (miniTheme.setCompletionDuration) { + miniTheme.setCompletionDuration(this.completionDuration); + } + + // Show a static snapshot at 25% progress + miniTheme.render(0.25, 45000); // 45 seconds remaining + + // Just show static preview - no animations + } + + selectTheme(themeId) { + this.selectedThemeId = themeId; + localStorage.setItem('lastSelectedTheme', themeId); + + // Update UI + document.querySelectorAll('.theme-card').forEach(card => { + card.classList.toggle('selected', card.dataset.themeId === themeId); + }); + + this.updateStartButton(); + } + + initializeEventListeners() { + // Start button + document.getElementById('start-button').addEventListener('click', () => { + this.startTimer(); + }); + + // Timer screen buttons will be added when timer starts + + // Quick timer buttons + document.querySelectorAll('.quick-timer').forEach(button => { + button.addEventListener('click', (e) => { + const minutes = parseInt(e.target.dataset.minutes); + const seconds = parseInt(e.target.dataset.seconds); + this.selectDuration(minutes, seconds); + + // Update selected state + document.querySelectorAll('.quick-timer').forEach(b => b.classList.remove('selected')); + e.target.classList.add('selected'); + }); + }); + + // Time input + const timeInput = document.getElementById('time-input'); + + // Parse time input + const parseTimeInput = (value) => { + // Remove all non-numeric characters except : + const cleaned = value.replace(/[^\d:]/g, ''); + + // Handle different formats + if (cleaned.includes(':')) { + const parts = cleaned.split(':'); + const minutes = parseInt(parts[0]) || 0; + const seconds = parseInt(parts[1]) || 0; + return { minutes: Math.min(999, minutes), seconds: Math.min(59, seconds) }; + } else { + // If no colon, treat as minutes if > 2 digits, otherwise seconds + const num = parseInt(cleaned) || 0; + if (cleaned.length > 2 || num > 59) { + return { minutes: Math.min(999, num), seconds: 0 }; + } else { + return { minutes: 0, seconds: num }; + } + } + }; + + // Format and update time input + const formatTimeInput = (minutes, seconds) => { + if (minutes === 0) { + return `0:${seconds.toString().padStart(2, '0')}`; + } + return `${minutes}:${seconds.toString().padStart(2, '0')}`; + }; + + timeInput.addEventListener('input', (e) => { + const { minutes, seconds } = parseTimeInput(e.target.value); + this.selectedDuration = { minutes, seconds }; + this.updateStartButton(); + document.querySelectorAll('.quick-timer').forEach(b => b.classList.remove('selected')); + }); + + // Format on blur + timeInput.addEventListener('blur', () => { + timeInput.value = formatTimeInput(this.selectedDuration.minutes, this.selectedDuration.seconds); + }); + + // Handle Enter key + timeInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + if (!document.getElementById('start-button').disabled) { + this.startTimer(); + } + } + }); + } + + selectDuration(minutes, seconds) { + this.selectedDuration = { minutes, seconds }; + const timeInput = document.getElementById('time-input'); + if (minutes === 0) { + timeInput.value = `0:${seconds.toString().padStart(2, '0')}`; + } else { + timeInput.value = `${minutes}:${seconds.toString().padStart(2, '0')}`; + } + this.updateStartButton(); + } + + + formatDuration(minutes, seconds) { + if (minutes === 0) { + return `${seconds}s`; + } else if (seconds === 0) { + return `${minutes}m`; + } else { + return `${minutes}m ${seconds}s`; + } + } + + updateStartButton() { + const totalSeconds = this.selectedDuration.minutes * 60 + this.selectedDuration.seconds; + const button = document.getElementById('start-button'); + button.disabled = totalSeconds < 1; + } + + resizeCanvas() { + if (this.canvas) { + this.canvas.width = window.innerWidth; + this.canvas.height = window.innerHeight; + } + } + + startTimer() { + const totalSeconds = this.selectedDuration.minutes * 60 + this.selectedDuration.seconds; + + if (totalSeconds < 1) { + alert('Please enter a valid duration (at least 1 second)'); + return; + } + + // Track theme popularity + this.themes[this.selectedThemeId].popularity++; + this.saveThemePopularity(); + + // Store settings for restart + this.currentSettings = { + theme: this.selectedThemeId, + duration: totalSeconds * 1000 + }; + + this.duration = totalSeconds * 1000; // Convert to milliseconds + this.startTime = Date.now(); + + // Store current content + this.setupContent = this.app.innerHTML; + + // Create timer screen + this.app.innerHTML = ` + + +
+ + +
+ `; + + // Set up timer screen + this.app.className = 'timer-active'; + this.canvas = document.getElementById('timer-canvas'); + this.ctx = this.canvas.getContext('2d'); + this.resizeCanvas(); + + // Create theme instance + const ThemeClass = this.themes[this.selectedThemeId].class; + this.currentTheme = new ThemeClass(this.canvas, this.ctx); + + // Pass completion duration to theme if it supports it + if (this.currentTheme.setCompletionDuration) { + this.currentTheme.setCompletionDuration(this.completionDuration); + } + + // Add event listeners for timer screen + document.getElementById('stop-button').addEventListener('click', () => this.stopTimer()); + document.getElementById('restart-button').addEventListener('click', () => this.restartTimer()); + document.getElementById('menu-button').addEventListener('click', () => this.backToMenu()); + + this.animate(); + } + + stopTimer() { + if (this.animationId) { + cancelAnimationFrame(this.animationId); + } + + this.backToMenu(); + } + + restartTimer() { + if (this.currentSettings) { + this.selectedThemeId = this.currentSettings.theme; + this.duration = this.currentSettings.duration; + this.startTime = Date.now(); + + const ThemeClass = this.themes[this.selectedThemeId].class; + this.currentTheme = new ThemeClass(this.canvas, this.ctx); + + if (this.currentTheme.setCompletionDuration) { + this.currentTheme.setCompletionDuration(this.completionDuration); + } + + // Hide completion controls + document.getElementById('completion-controls').classList.remove('active'); + document.getElementById('stop-button').style.display = 'block'; + + this.animate(); + } + } + + backToMenu() { + if (this.animationId) { + cancelAnimationFrame(this.animationId); + } + + // Restore setup screen + this.app.className = ''; + this.app.innerHTML = this.setupContent; + + // Re-initialize everything + this.initializeThemeGallery(); + this.initializeEventListeners(); + + // Restore the selected duration in the UI + const timeInput = document.getElementById('time-input'); + if (timeInput) { + const minutes = this.selectedDuration.minutes; + const seconds = this.selectedDuration.seconds; + if (minutes === 0) { + timeInput.value = `0:${seconds.toString().padStart(2, '0')}`; + } else { + timeInput.value = `${minutes}:${seconds.toString().padStart(2, '0')}`; + } + } + + // Update quick timer selection + const totalSeconds = this.selectedDuration.minutes * 60 + this.selectedDuration.seconds; + document.querySelectorAll('.quick-timer').forEach(button => { + const btnMinutes = parseInt(button.dataset.minutes); + const btnSeconds = parseInt(button.dataset.seconds); + const btnTotal = btnMinutes * 60 + btnSeconds; + button.classList.toggle('selected', btnTotal === totalSeconds); + }); + } + + animate() { + const now = Date.now(); + const elapsed = now - this.startTime; + const progress = Math.min(elapsed / this.duration, 1.0); + const remainingTime = Math.max(0, this.duration - elapsed); + + this.currentTheme.render(progress, remainingTime); + + if (progress < 1.0) { + this.animationId = requestAnimationFrame(() => this.animate()); + } else { + this.onTimerComplete(); + } + } + + onTimerComplete() { + // Hide stop button, show completion controls + document.getElementById('stop-button').style.display = 'none'; + document.getElementById('completion-controls').classList.add('active'); + + if (this.currentTheme.onComplete) { + this.currentTheme.onComplete(); + } + } +} + +// Initialize app when DOM is ready +document.addEventListener('DOMContentLoaded', () => { + new TimerApp(); +}); \ No newline at end of file diff --git a/src/styles.css b/src/styles.css new file mode 100644 index 0000000..bf6d5c8 --- /dev/null +++ b/src/styles.css @@ -0,0 +1,310 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +:root { + --bg-primary: #0a0a0a; + --bg-secondary: #141414; + --bg-card: #1a1a1a; + --bg-hover: #242424; + --text-primary: #ffffff; + --text-secondary: #a0a0a0; + --accent: #3b82f6; + --accent-hover: #2563eb; + --border: #2a2a2a; + --success: #10b981; + --danger: #ef4444; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background-color: var(--bg-primary); + color: var(--text-primary); + overflow: hidden; + line-height: 1.6; +} + +#app { + display: flex; + flex-direction: column; + align-items: center; + gap: 1.5rem; + min-height: 100vh; + padding: 1.5rem; + overflow-y: auto; +} + +#app h1 { + font-size: 2.5rem; + font-weight: 700; + margin: 0; +} + +/* Timer Setup */ +.timer-setup { + display: flex; + gap: 1rem; + align-items: center; +} + +/* Theme Grid */ +.theme-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 1rem; + width: 100%; + max-width: 1000px; +} + +.theme-card { + background: var(--bg-card); + border: 2px solid var(--border); + border-radius: 8px; + overflow: hidden; + cursor: pointer; + position: relative; +} + +.theme-card:hover { + border-color: var(--accent); +} + +.theme-card.selected { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.3); +} + +.theme-preview { + width: 100%; + height: 100px; + position: relative; + overflow: hidden; +} + +.theme-preview canvas { + width: 100%; + height: 100%; +} + +.theme-info { + padding: 0.75rem; +} + +.theme-name { + font-size: 0.95rem; + font-weight: 600; + margin-bottom: 0.125rem; +} + +.theme-description { + font-size: 0.75rem; + color: var(--text-secondary); +} + +.popularity-badge { + position: absolute; + top: 0.5rem; + right: 0.5rem; + background: rgba(0, 0, 0, 0.7); + color: #fbbf24; + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.75rem; + display: flex; + align-items: center; + gap: 0.25rem; +} + +/* Time Input */ +.time-input { + font-size: 2.5rem; + font-weight: 300; + font-family: 'SF Mono', 'Monaco', 'Consolas', monospace; + background: transparent; + border: none; + border-bottom: 2px solid var(--border); + color: var(--text-primary); + text-align: center; + padding: 0.25rem; + width: 150px; +} + +.time-input:focus { + outline: none; + border-bottom-color: var(--accent); +} + +.quick-timers { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + justify-content: center; +} + +.quick-timer { + background: var(--bg-secondary); + border: 1px solid var(--border); + color: var(--text-secondary); + padding: 0.5rem 1rem; + border-radius: 20px; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; +} + +.quick-timer:hover { + background: var(--bg-hover); + border-color: var(--accent); + color: var(--text-primary); +} + +.quick-timer.selected { + background: var(--accent); + border-color: var(--accent); + color: white; +} + +/* Start Button */ +.start-button { + background: var(--accent); + color: white; + border: none; + padding: 1rem 3rem; + font-size: 1.25rem; + font-weight: 600; + border-radius: 8px; + cursor: pointer; + display: block; + margin: 0 auto; + box-shadow: 0 4px 20px rgba(59, 130, 246, 0.3); +} + +.start-button:hover { + background: var(--accent-hover); +} + + +.start-button:disabled { + background: var(--bg-hover); + color: var(--text-secondary); + cursor: not-allowed; + box-shadow: none; +} + +/* Timer Screen */ +.timer-active { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + position: relative; + width: 100vw; + height: 100vh; + padding: 0; + gap: 0; +} + +#timer-canvas { + width: 100vw; + height: 100vh; + position: absolute; + top: 0; + left: 0; +} + +#stop-button { + position: absolute; + bottom: 2rem; + left: 50%; + transform: translateX(-50%); + background: rgba(239, 68, 68, 0.9); + backdrop-filter: blur(10px); + color: white; + border: none; + padding: 1rem 2rem; + font-size: 1.1rem; + font-weight: 600; + border-radius: 8px; + cursor: pointer; +} + +#stop-button:hover { + background: rgba(220, 38, 38, 0.9); +} + +.completion-controls { + display: none; + position: absolute; + bottom: 2rem; + left: 50%; + transform: translateX(-50%); + gap: 2rem; +} + +.completion-controls.active { + display: flex; +} + +.control-button { + width: 60px; + height: 60px; + border-radius: 50%; + background-color: rgba(255, 255, 255, 0.1); + border: 2px solid rgba(255, 255, 255, 0.3); + color: #ffffff; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + backdrop-filter: blur(10px); +} + +.control-button:hover { + background-color: rgba(255, 255, 255, 0.2); + border-color: rgba(255, 255, 255, 0.5); +} + +.control-button svg { + width: 30px; + height: 30px; +} + +/* Scrollbar styling */ +::-webkit-scrollbar { + width: 8px; +} + +::-webkit-scrollbar-track { + background: var(--bg-secondary); +} + +::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--bg-hover); +} + +/* Responsive */ +@media (max-width: 768px) { + #setup-screen { + padding: 1rem; + } + + .header h1 { + font-size: 2.5rem; + } + + .theme-grid { + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 1rem; + } + + .quick-timers { + justify-content: center; + } +} \ No newline at end of file diff --git a/src/themes/circleShaderTheme.js b/src/themes/circleShaderTheme.js new file mode 100644 index 0000000..bfbdada --- /dev/null +++ b/src/themes/circleShaderTheme.js @@ -0,0 +1,253 @@ +export class CircleShaderTheme { + constructor(canvas, ctx) { + this.canvas = canvas; + this.ctx = ctx; + this.gl = null; + this.program = null; + this.animationFrame = 0; + this.completionDuration = 2400; // milliseconds to reach resting state + this.initWebGL(); + } + + setCompletionDuration(duration) { + this.completionDuration = duration; + } + + initWebGL() { + // Create a temporary canvas for WebGL + this.glCanvas = document.createElement('canvas'); + this.glCanvas.width = this.canvas.width; + this.glCanvas.height = this.canvas.height; + this.gl = this.glCanvas.getContext('webgl') || this.glCanvas.getContext('experimental-webgl'); + + if (!this.gl) { + console.error('WebGL not supported'); + return; + } + + // Vertex shader + const vertexShaderSource = ` + attribute vec2 a_position; + varying vec2 v_uv; + + void main() { + gl_Position = vec4(a_position, 0.0, 1.0); + v_uv = (a_position + 1.0) * 0.5; + } + `; + + // Fragment shader - circle segment + const fragmentShaderSource = ` + precision mediump float; + + uniform float u_time; + uniform float u_progress; + uniform vec2 u_resolution; + varying vec2 v_uv; + + void main() { + vec2 uv = v_uv; + vec2 p = (uv - 0.5) * 2.0; + float aspect = u_resolution.x / u_resolution.y; + p.x *= aspect; + + float dist = length(p); + float angle = atan(p.y, p.x); + + // Background color + vec3 bgColor = vec3(0.1, 0.1, 0.1); + vec3 color = bgColor; + + // Circle parameters + float radius = 0.7; + float thickness = 0.04; + + // Background circle (gray) + float bgCircle = abs(dist - radius); + if (bgCircle < thickness * 0.5) { + float edge = smoothstep(thickness * 0.5, thickness * 0.45, bgCircle); + color = mix(color, vec3(0.2), edge); + } + + // Progress arc + float startAngle = -1.5708; // Start from top (-PI/2) + float endAngle = startAngle + (6.28318 * (1.0 - u_progress)); + float normalizedAngle = mod(angle - startAngle, 6.28318); + + if (normalizedAngle < endAngle - startAngle) { + float arcDist = abs(dist - radius); + if (arcDist < thickness) { + float edge = smoothstep(thickness, thickness * 0.8, arcDist); + + // Color gradient from green to red + float r = u_progress; + float g = 1.0 - u_progress; + vec3 arcColor = vec3(r, g, 0.4); + + color = mix(color, arcColor, edge); + } + } + + // Add subtle glow + float glow = exp(-pow(dist - radius, 2.0) * 50.0) * 0.3; + color += vec3(u_progress, 1.0 - u_progress, 0.4) * glow * (1.0 - u_progress); + + // Completion state + if (u_progress > 1.0) { + float completion = min((u_progress - 1.0) * 2.0, 1.0); + + // Fade out + color *= 1.0 - completion * 0.7; + + // Pulsing ring that slows down with completion + float pulseSpeed = mix(2.0, 0.5, completion); + float pulse = sin(u_time * pulseSpeed) * 0.05 + 0.95; + float ringDist = abs(dist - radius * pulse); + if (ringDist < thickness * 2.0) { + float ringEdge = smoothstep(thickness * 2.0, thickness * 1.5, ringDist); + color += vec3(1.0, 0.2, 0.2) * ringEdge * completion * 0.5; + } + } + + gl_FragColor = vec4(color, 1.0); + } + `; + + // Create and compile shaders + const vertexShader = this.createShader(this.gl.VERTEX_SHADER, vertexShaderSource); + const fragmentShader = this.createShader(this.gl.FRAGMENT_SHADER, fragmentShaderSource); + + if (!vertexShader || !fragmentShader) return; + + // Create program + this.program = this.gl.createProgram(); + this.gl.attachShader(this.program, vertexShader); + this.gl.attachShader(this.program, fragmentShader); + this.gl.linkProgram(this.program); + + if (!this.gl.getProgramParameter(this.program, this.gl.LINK_STATUS)) { + console.error('Unable to initialize the shader program:', this.gl.getProgramInfoLog(this.program)); + return; + } + + // Set up geometry + const positions = new Float32Array([ + -1, -1, + 1, -1, + -1, 1, + 1, 1 + ]); + + const positionBuffer = this.gl.createBuffer(); + this.gl.bindBuffer(this.gl.ARRAY_BUFFER, positionBuffer); + this.gl.bufferData(this.gl.ARRAY_BUFFER, positions, this.gl.STATIC_DRAW); + + const positionLocation = this.gl.getAttribLocation(this.program, 'a_position'); + this.gl.enableVertexAttribArray(positionLocation); + this.gl.vertexAttribPointer(positionLocation, 2, this.gl.FLOAT, false, 0, 0); + + // Get uniform locations + this.uniforms = { + time: this.gl.getUniformLocation(this.program, 'u_time'), + progress: this.gl.getUniformLocation(this.program, 'u_progress'), + resolution: this.gl.getUniformLocation(this.program, 'u_resolution') + }; + } + + createShader(type, source) { + const shader = this.gl.createShader(type); + this.gl.shaderSource(shader, source); + this.gl.compileShader(shader); + + if (!this.gl.getShaderParameter(shader, this.gl.COMPILE_STATUS)) { + console.error('An error occurred compiling the shaders:', this.gl.getShaderInfoLog(shader)); + this.gl.deleteShader(shader); + return null; + } + + return shader; + } + + render(progress, remainingTime) { + this.animationFrame++; + + // Clear main canvas + this.ctx.fillStyle = '#1a1a1a'; + this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); + + if (this.gl && this.program) { + // Resize WebGL canvas if needed + if (this.glCanvas.width !== this.canvas.width || this.glCanvas.height !== this.canvas.height) { + this.glCanvas.width = this.canvas.width; + this.glCanvas.height = this.canvas.height; + this.gl.viewport(0, 0, this.glCanvas.width, this.glCanvas.height); + } + + // Clear WebGL canvas + this.gl.clearColor(0.1, 0.1, 0.1, 1.0); + this.gl.clear(this.gl.COLOR_BUFFER_BIT); + + // Use shader program + this.gl.useProgram(this.program); + + // Set uniforms + this.gl.uniform1f(this.uniforms.time, this.animationFrame * 0.01); + this.gl.uniform1f(this.uniforms.progress, progress); + this.gl.uniform2f(this.uniforms.resolution, this.glCanvas.width, this.glCanvas.height); + + // Draw + this.gl.drawArrays(this.gl.TRIANGLE_STRIP, 0, 4); + + // Copy WebGL canvas to main canvas + this.ctx.drawImage(this.glCanvas, 0, 0); + } + + // Draw timer text on top + const minutes = Math.floor(remainingTime / 60000); + const seconds = Math.floor((remainingTime % 60000) / 1000); + const timeString = `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; + + const centerX = this.canvas.width / 2; + const centerY = this.canvas.height / 2; + const fontSize = Math.min(this.canvas.width, this.canvas.height) * 0.175; + + this.ctx.font = `${fontSize}px Arial`; + this.ctx.fillStyle = '#ffffff'; + this.ctx.textAlign = 'center'; + this.ctx.textBaseline = 'middle'; + this.ctx.fillText(timeString, centerX, centerY); + } + + onComplete() { + let completionFrame = 0; + const startTime = Date.now(); + + const animate = () => { + completionFrame++; + const elapsed = Date.now() - startTime; + const restingProgress = Math.min(elapsed / this.completionDuration, 1.0); + + if (this.gl && this.program) { + this.ctx.fillStyle = '#1a1a1a'; + this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); + + this.gl.clearColor(0.1, 0.1, 0.1, 1.0); + this.gl.clear(this.gl.COLOR_BUFFER_BIT); + + this.gl.useProgram(this.program); + + const progress = 1.0 + (restingProgress * 0.5); + this.gl.uniform1f(this.uniforms.time, elapsed * 0.001); + this.gl.uniform1f(this.uniforms.progress, progress); + this.gl.uniform2f(this.uniforms.resolution, this.glCanvas.width, this.glCanvas.height); + + this.gl.drawArrays(this.gl.TRIANGLE_STRIP, 0, 4); + + this.ctx.drawImage(this.glCanvas, 0, 0); + } + + requestAnimationFrame(animate); + }; + animate(); + } +} \ No newline at end of file diff --git a/src/themes/circularBlobsShaderTheme.js b/src/themes/circularBlobsShaderTheme.js new file mode 100644 index 0000000..cd3b84d --- /dev/null +++ b/src/themes/circularBlobsShaderTheme.js @@ -0,0 +1,270 @@ +export class CircularBlobsShaderTheme { + constructor(canvas, ctx) { + this.canvas = canvas; + this.ctx = ctx; + this.gl = null; + this.program = null; + this.animationFrame = 0; + this.completionDuration = 2400; // milliseconds to reach resting state + this.initWebGL(); + } + + setCompletionDuration(duration) { + this.completionDuration = duration; + } + + initWebGL() { + // Create a temporary canvas for WebGL + this.glCanvas = document.createElement('canvas'); + this.glCanvas.width = this.canvas.width; + this.glCanvas.height = this.canvas.height; + this.gl = this.glCanvas.getContext('webgl') || this.glCanvas.getContext('experimental-webgl'); + + if (!this.gl) { + console.error('WebGL not supported'); + return; + } + + // Vertex shader + const vertexShaderSource = ` + attribute vec2 a_position; + varying vec2 v_uv; + + void main() { + gl_Position = vec4(a_position, 0.0, 1.0); + v_uv = (a_position + 1.0) * 0.5; + } + `; + + // Fragment shader - circular blobs + const fragmentShaderSource = ` + precision mediump float; + + uniform float u_time; + uniform float u_progress; + uniform vec2 u_resolution; + varying vec2 v_uv; + + float easeInBack(float t) { + float c1 = 1.70158; + float c3 = c1 + 1.0; + return c3 * t * t * t - c1 * t * t; + } + + void main() { + vec2 uv = v_uv; + vec2 p = (uv - 0.5) * 2.0; + float aspect = u_resolution.x / u_resolution.y; + p.x *= aspect; + + vec3 color = vec3(0.0); + float numBlobs = 12.0; + + // Draw blobs in a circle + for (float i = 0.0; i < 12.0; i++) { + float angle = (i / numBlobs) * 6.28318 - 1.5708; // Start from top + float radius = 0.6; + vec2 blobPos = vec2(cos(angle), sin(angle)) * radius; + + // Calculate which blobs should be consumed + float blobProgress = u_progress * numBlobs; + float isConsumed = 0.0; + float localProgress = 0.0; + + if (i < blobProgress) { + isConsumed = 1.0; + } + + // For the blob currently being consumed + if (i < blobProgress && i + 1.0 > blobProgress) { + localProgress = fract(blobProgress); + // Apply easing + localProgress = easeInBack(localProgress); + isConsumed = localProgress; + } + + // Only render if not fully consumed + if (isConsumed < 1.0) { + // Blob size based on consumption + float blobRadius = 0.06 * (1.0 - isConsumed); + float dist = distance(p, blobPos); + + // Smooth blob + float blob = 1.0 - smoothstep(blobRadius - 0.01, blobRadius, dist); + + // Color based on progress + float hue = 120.0 - (u_progress * 120.0); + vec3 blobColor = vec3( + 0.5 + 0.5 * cos(radians(hue)), + 0.5 + 0.5 * cos(radians(hue - 120.0)), + 0.5 + 0.5 * cos(radians(hue - 240.0)) + ); + + // Add glow + float glow = exp(-dist * 20.0) * 0.3 * (1.0 - isConsumed); + + + color += blobColor * (blob + glow); + } + } + + // No center dot - keep centerDist for completion effect + float centerDist = length(p); + + // Completion state - calm fade + if (u_progress > 1.0) { + float completion = min((u_progress - 1.0) * 2.0, 1.0); + + // Simple fade to dark + color *= 1.0 - completion * 0.8; + + // Gentle breathing center dot - slows down with completion + float breathSpeed = mix(1.5, 0.5, completion); + float breathe = sin(u_time * breathSpeed) * 0.05 + 0.95; + float centerGlow = 1.0 - smoothstep(0.0, 0.15 * breathe, centerDist); + color += vec3(0.8, 0.3, 0.3) * centerGlow * completion * 0.5; + } + + gl_FragColor = vec4(color, 1.0); + } + `; + + // Create and compile shaders + const vertexShader = this.createShader(this.gl.VERTEX_SHADER, vertexShaderSource); + const fragmentShader = this.createShader(this.gl.FRAGMENT_SHADER, fragmentShaderSource); + + if (!vertexShader || !fragmentShader) return; + + // Create program + this.program = this.gl.createProgram(); + this.gl.attachShader(this.program, vertexShader); + this.gl.attachShader(this.program, fragmentShader); + this.gl.linkProgram(this.program); + + if (!this.gl.getProgramParameter(this.program, this.gl.LINK_STATUS)) { + console.error('Unable to initialize the shader program:', this.gl.getProgramInfoLog(this.program)); + return; + } + + // Set up geometry (full screen quad) + const positions = new Float32Array([ + -1, -1, + 1, -1, + -1, 1, + 1, 1 + ]); + + const positionBuffer = this.gl.createBuffer(); + this.gl.bindBuffer(this.gl.ARRAY_BUFFER, positionBuffer); + this.gl.bufferData(this.gl.ARRAY_BUFFER, positions, this.gl.STATIC_DRAW); + + const positionLocation = this.gl.getAttribLocation(this.program, 'a_position'); + this.gl.enableVertexAttribArray(positionLocation); + this.gl.vertexAttribPointer(positionLocation, 2, this.gl.FLOAT, false, 0, 0); + + // Get uniform locations + this.uniforms = { + time: this.gl.getUniformLocation(this.program, 'u_time'), + progress: this.gl.getUniformLocation(this.program, 'u_progress'), + resolution: this.gl.getUniformLocation(this.program, 'u_resolution') + }; + } + + createShader(type, source) { + const shader = this.gl.createShader(type); + this.gl.shaderSource(shader, source); + this.gl.compileShader(shader); + + if (!this.gl.getShaderParameter(shader, this.gl.COMPILE_STATUS)) { + console.error('An error occurred compiling the shaders:', this.gl.getShaderInfoLog(shader)); + this.gl.deleteShader(shader); + return null; + } + + return shader; + } + + render(progress, remainingTime) { + this.animationFrame++; + + // Clear main canvas + this.ctx.fillStyle = '#0a0a0a'; + this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); + + if (this.gl && this.program) { + // Resize WebGL canvas if needed + if (this.glCanvas.width !== this.canvas.width || this.glCanvas.height !== this.canvas.height) { + this.glCanvas.width = this.canvas.width; + this.glCanvas.height = this.canvas.height; + this.gl.viewport(0, 0, this.glCanvas.width, this.glCanvas.height); + } + + // Clear WebGL canvas + this.gl.clearColor(0.04, 0.04, 0.04, 1.0); + this.gl.clear(this.gl.COLOR_BUFFER_BIT); + + // Use shader program + this.gl.useProgram(this.program); + + // Set uniforms + this.gl.uniform1f(this.uniforms.time, this.animationFrame * 0.01); + this.gl.uniform1f(this.uniforms.progress, progress); + this.gl.uniform2f(this.uniforms.resolution, this.glCanvas.width, this.glCanvas.height); + + // Draw + this.gl.drawArrays(this.gl.TRIANGLE_STRIP, 0, 4); + + // Copy WebGL canvas to main canvas + this.ctx.drawImage(this.glCanvas, 0, 0); + } + + // Draw timer text on top + const minutes = Math.floor(remainingTime / 60000); + const seconds = Math.floor((remainingTime % 60000) / 1000); + const timeString = `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; + + const centerX = this.canvas.width / 2; + const centerY = this.canvas.height / 2; + + this.ctx.font = '48px Arial'; + this.ctx.fillStyle = '#ffffff'; + this.ctx.textAlign = 'center'; + this.ctx.textBaseline = 'middle'; + this.ctx.fillText(timeString, centerX, centerY); + } + + onComplete() { + let completionFrame = 0; + const startTime = Date.now(); + + const animate = () => { + completionFrame++; + const elapsed = Date.now() - startTime; + const restingProgress = Math.min(elapsed / this.completionDuration, 1.0); + + // Continue using shader for completion + if (this.gl && this.program) { + this.ctx.fillStyle = '#0a0a0a'; + this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); + + this.gl.clearColor(0.04, 0.04, 0.04, 1.0); + this.gl.clear(this.gl.COLOR_BUFFER_BIT); + + this.gl.useProgram(this.program); + + const progress = 1.0 + (restingProgress * 0.5); + // Use elapsed time for consistent animation speed + this.gl.uniform1f(this.uniforms.time, elapsed * 0.001); + this.gl.uniform1f(this.uniforms.progress, progress); + this.gl.uniform2f(this.uniforms.resolution, this.glCanvas.width, this.glCanvas.height); + + this.gl.drawArrays(this.gl.TRIANGLE_STRIP, 0, 4); + + this.ctx.drawImage(this.glCanvas, 0, 0); + } + + requestAnimationFrame(animate); + }; + animate(); + } +} \ No newline at end of file diff --git a/src/themes/rainbowShaderTheme.js b/src/themes/rainbowShaderTheme.js new file mode 100644 index 0000000..02ba888 --- /dev/null +++ b/src/themes/rainbowShaderTheme.js @@ -0,0 +1,275 @@ +export class RainbowShaderTheme { + constructor(canvas, ctx) { + this.canvas = canvas; + this.ctx = ctx; + this.gl = null; + this.program = null; + this.animationFrame = 0; + this.completionDuration = 2400; // milliseconds to reach resting state + this.initWebGL(); + } + + setCompletionDuration(duration) { + this.completionDuration = duration; + } + + initWebGL() { + // Create a temporary canvas for WebGL + this.glCanvas = document.createElement('canvas'); + this.glCanvas.width = this.canvas.width; + this.glCanvas.height = this.canvas.height; + this.gl = this.glCanvas.getContext('webgl') || this.glCanvas.getContext('experimental-webgl'); + + if (!this.gl) { + console.error('WebGL not supported'); + return; + } + + // Vertex shader + const vertexShaderSource = ` + attribute vec2 a_position; + varying vec2 v_uv; + + void main() { + gl_Position = vec4(a_position, 0.0, 1.0); + v_uv = (a_position + 1.0) * 0.5; + } + `; + + // Fragment shader - rainbow circles + const fragmentShaderSource = ` + precision mediump float; + + uniform float u_time; + uniform float u_progress; + uniform vec2 u_resolution; + varying vec2 v_uv; + + 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 = v_uv; + vec2 p = (uv - 0.5) * 2.0; + float aspect = u_resolution.x / u_resolution.y; + p.x *= aspect; + + vec3 color = vec3(0.0); + float centerDist = length(p); + + // Multiple animated circles + for (float i = 0.0; i < 6.0; i++) { + float angle = (i / 6.0) * 6.28318; + float orbitRadius = 0.3; + float speed = 0.5 + i * 0.2; + + // Orbit position + float orbitAngle = angle + u_time * speed * (mod(i, 2.0) * 2.0 - 1.0); + vec2 circlePos = vec2(cos(orbitAngle), sin(orbitAngle)) * orbitRadius; + + // Circle properties + float radius = 0.08 + i * 0.02; + radius *= (1.0 - u_progress); + + float dist = distance(p, circlePos); + float circle = 1.0 - smoothstep(radius - 0.01, radius, dist); + + // Rainbow color + vec3 hsvColor = vec3(i / 6.0, 1.0, 1.0); + vec3 rgbColor = hsv2rgb(hsvColor); + + // Add glow + float glow = exp(-dist * dist / (radius * radius * 2.0)) * 0.5; + + color += rgbColor * (circle + glow) * (1.0 - u_progress); + } + + // Center pulse + float centerRadius = 0.05 * (1.0 + sin(u_time * 3.0) * 0.1); + float centerCircle = 1.0 - smoothstep(centerRadius - 0.01, centerRadius, centerDist); + color += vec3(1.0) * centerCircle * 0.8; + + // Add trails + vec3 trailColor = vec3(0.0); + for (float i = 0.0; i < 6.0; i++) { + float hue = i / 6.0 + u_time * 0.1; + vec3 rainbow = hsv2rgb(vec3(mod(hue, 1.0), 1.0, 0.5)); + trailColor += rainbow * 0.02 * (1.0 - u_progress); + } + color += trailColor; + + // Completion state + if (u_progress > 1.0) { + float completion = min((u_progress - 1.0) * 2.0, 1.0); + + // Fade to dark + color *= 1.0 - completion * 0.9; + + // Gentle rainbow pulse at center + float breathSpeed = mix(2.0, 0.5, completion); + float breathe = sin(u_time * breathSpeed) * 0.1 + 0.9; + + // Create a soft rainbow gradient that pulses + float gradientRadius = 0.3 * breathe; + float gradientFade = 1.0 - smoothstep(0.0, gradientRadius, centerDist); + + // Rotating hue + float hue = u_time * 0.5; + vec3 pulseColor = hsv2rgb(vec3(mod(hue, 1.0), 0.6, 0.8)); + + color += pulseColor * gradientFade * completion * 0.4; + } + + gl_FragColor = vec4(color, 1.0); + } + `; + + // Create and compile shaders + const vertexShader = this.createShader(this.gl.VERTEX_SHADER, vertexShaderSource); + const fragmentShader = this.createShader(this.gl.FRAGMENT_SHADER, fragmentShaderSource); + + if (!vertexShader || !fragmentShader) return; + + // Create program + this.program = this.gl.createProgram(); + this.gl.attachShader(this.program, vertexShader); + this.gl.attachShader(this.program, fragmentShader); + this.gl.linkProgram(this.program); + + if (!this.gl.getProgramParameter(this.program, this.gl.LINK_STATUS)) { + console.error('Unable to initialize the shader program:', this.gl.getProgramInfoLog(this.program)); + return; + } + + // Set up geometry + const positions = new Float32Array([ + -1, -1, + 1, -1, + -1, 1, + 1, 1 + ]); + + const positionBuffer = this.gl.createBuffer(); + this.gl.bindBuffer(this.gl.ARRAY_BUFFER, positionBuffer); + this.gl.bufferData(this.gl.ARRAY_BUFFER, positions, this.gl.STATIC_DRAW); + + const positionLocation = this.gl.getAttribLocation(this.program, 'a_position'); + this.gl.enableVertexAttribArray(positionLocation); + this.gl.vertexAttribPointer(positionLocation, 2, this.gl.FLOAT, false, 0, 0); + + // Get uniform locations + this.uniforms = { + time: this.gl.getUniformLocation(this.program, 'u_time'), + progress: this.gl.getUniformLocation(this.program, 'u_progress'), + resolution: this.gl.getUniformLocation(this.program, 'u_resolution') + }; + } + + createShader(type, source) { + const shader = this.gl.createShader(type); + this.gl.shaderSource(shader, source); + this.gl.compileShader(shader); + + if (!this.gl.getShaderParameter(shader, this.gl.COMPILE_STATUS)) { + console.error('An error occurred compiling the shaders:', this.gl.getShaderInfoLog(shader)); + this.gl.deleteShader(shader); + return null; + } + + return shader; + } + + render(progress, remainingTime) { + this.animationFrame++; + + // Clear main canvas with slight trail + this.ctx.fillStyle = 'rgba(26, 26, 26, 0.1)'; + this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); + + if (this.gl && this.program) { + // Resize WebGL canvas if needed + if (this.glCanvas.width !== this.canvas.width || this.glCanvas.height !== this.canvas.height) { + this.glCanvas.width = this.canvas.width; + this.glCanvas.height = this.canvas.height; + this.gl.viewport(0, 0, this.glCanvas.width, this.glCanvas.height); + } + + // Clear WebGL canvas + this.gl.clearColor(0.1, 0.1, 0.1, 0.0); + this.gl.clear(this.gl.COLOR_BUFFER_BIT); + + // Enable blending for trails + this.gl.enable(this.gl.BLEND); + this.gl.blendFunc(this.gl.SRC_ALPHA, this.gl.ONE); + + // Use shader program + this.gl.useProgram(this.program); + + // Set uniforms + this.gl.uniform1f(this.uniforms.time, this.animationFrame * 0.01); + this.gl.uniform1f(this.uniforms.progress, progress); + this.gl.uniform2f(this.uniforms.resolution, this.glCanvas.width, this.glCanvas.height); + + // Draw + this.gl.drawArrays(this.gl.TRIANGLE_STRIP, 0, 4); + + // Copy WebGL canvas to main canvas + this.ctx.drawImage(this.glCanvas, 0, 0); + } + + // Draw timer text on top + const minutes = Math.floor(remainingTime / 60000); + const seconds = Math.floor((remainingTime % 60000) / 1000); + const timeString = `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; + + const centerX = this.canvas.width / 2; + const centerY = this.canvas.height / 2; + const fontSize = 60; + + // Black background for text + this.ctx.fillStyle = 'rgba(0, 0, 0, 0.7)'; + this.ctx.fillRect(centerX - 150, centerY - 60, 300, 120); + + this.ctx.font = `bold ${fontSize}px Arial`; + this.ctx.fillStyle = '#ffffff'; + this.ctx.textAlign = 'center'; + this.ctx.textBaseline = 'middle'; + this.ctx.fillText(timeString, centerX, centerY); + } + + onComplete() { + let completionFrame = 0; + const startTime = Date.now(); + + const animate = () => { + completionFrame++; + const elapsed = Date.now() - startTime; + const restingProgress = Math.min(elapsed / this.completionDuration, 1.0); + + if (this.gl && this.program) { + this.ctx.fillStyle = 'rgba(26, 26, 26, 0.05)'; + this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); + + this.gl.clearColor(0.1, 0.1, 0.1, 0.0); + this.gl.clear(this.gl.COLOR_BUFFER_BIT); + + this.gl.useProgram(this.program); + + const progress = 1.0 + (restingProgress * 0.5); + this.gl.uniform1f(this.uniforms.time, elapsed * 0.001); + this.gl.uniform1f(this.uniforms.progress, progress); + this.gl.uniform2f(this.uniforms.resolution, this.glCanvas.width, this.glCanvas.height); + + this.gl.drawArrays(this.gl.TRIANGLE_STRIP, 0, 4); + + this.ctx.drawImage(this.glCanvas, 0, 0); + } + + requestAnimationFrame(animate); + }; + animate(); + } +} \ No newline at end of file diff --git a/src/themes/shaderTheme.js b/src/themes/shaderTheme.js new file mode 100644 index 0000000..f43902c --- /dev/null +++ b/src/themes/shaderTheme.js @@ -0,0 +1,319 @@ +export class ShaderTheme { + constructor(canvas, ctx) { + this.canvas = canvas; + this.ctx = ctx; + this.gl = null; + this.program = null; + this.animationFrame = 0; + this.completionDuration = 2400; // milliseconds to reach resting state + this.initWebGL(); + } + + setCompletionDuration(duration) { + this.completionDuration = duration; + } + + initWebGL() { + // Create a temporary canvas for WebGL + this.glCanvas = document.createElement('canvas'); + this.glCanvas.width = this.canvas.width; + this.glCanvas.height = this.canvas.height; + this.gl = this.glCanvas.getContext('webgl') || this.glCanvas.getContext('experimental-webgl'); + + if (!this.gl) { + console.error('WebGL not supported'); + return; + } + + // Vertex shader - simple quad + const vertexShaderSource = ` + attribute vec2 a_position; + varying vec2 v_uv; + + void main() { + gl_Position = vec4(a_position, 0.0, 1.0); + v_uv = (a_position + 1.0) * 0.5; + } + `; + + // Fragment shader - elegant animated timer + const fragmentShaderSource = ` + precision mediump float; + + uniform float u_time; + uniform float u_progress; + uniform vec2 u_resolution; + varying vec2 v_uv; + + void main() { + vec2 uv = v_uv; + vec2 p = (uv - 0.5) * 2.0; + float aspect = u_resolution.x / u_resolution.y; + p.x *= aspect; + + float dist = length(p); + float angle = atan(p.y, p.x); + + // Create multiple rotating rings that deplete like a clock + float rings = 0.0; + for (float i = 0.0; i < 3.0; i++) { + float ringRadius = 0.3 + i * 0.15; + float ringSpeed = 0.5 + i * 0.3; + + // Each ring depletes at different rates + float ringProgress = u_progress * (1.0 + i * 0.3); + ringProgress = min(ringProgress, 1.0); + + // Create clock-like depletion starting from top + float depletionAngle = ringProgress * 6.28318; + float adjustedAngle = mod(angle + 1.5708, 6.28318); // Start from top + float arcMask = 1.0 - smoothstep(depletionAngle - 0.1, depletionAngle, adjustedAngle); + + // Rotating effect + float rotationOffset = u_time * ringSpeed * (mod(i, 2.0) * 2.0 - 1.0); + + float ring = abs(dist - ringRadius); + float ringMask = 1.0 - smoothstep(0.015, 0.025, ring); + + // Add subtle rotation animation to the pattern + float pattern = sin(angle * 8.0 + rotationOffset) * 0.3 + 0.7; + + rings += ringMask * arcMask * pattern * (1.0 - i * 0.15); + } + + // Color gradient based on progress + vec3 color1 = vec3(0.2, 0.6, 1.0); // Cool blue + vec3 color2 = vec3(1.0, 0.5, 0.2); // Warm orange + vec3 color3 = vec3(1.0, 0.2, 0.3); // Red + + vec3 color; + if (u_progress < 0.5) { + color = mix(color1, color2, u_progress * 2.0); + } else { + color = mix(color2, color3, (u_progress - 0.5) * 2.0); + } + + // Center circle that pulses + float centerRadius = 0.15 * (1.0 + sin(u_time * 3.0) * 0.1); + float centerMask = 1.0 - smoothstep(centerRadius - 0.02, centerRadius, dist); + + // Combine elements + vec3 finalColor = color * rings; + finalColor += color * centerMask * 0.8; + + // Add radial gradient fade + float fade = 1.0 - smoothstep(0.0, 1.2, dist); + finalColor *= fade; + + // Subtle overall pulse + finalColor *= 0.9 + sin(u_time * 2.0 + dist * 5.0) * 0.1; + + // Completion state - rings collapse inward + if (u_progress > 1.0) { + float completion = min((u_progress - 1.0) * 2.0, 1.0); + + // Rings collapse to center + float collapse = 1.0 - completion; + + // Create imploding rings effect + for (float i = 0.0; i < 5.0; i++) { + float ringTime = completion + i * 0.1; + float ringRadius = (0.8 - ringTime) * collapse; + if (ringRadius > 0.0) { + float ring = abs(dist - ringRadius); + float ringMask = 1.0 - smoothstep(0.01, 0.03, ring); + float ringFade = 1.0 - ringTime; + finalColor += color * ringMask * ringFade * 0.5; + } + } + + // Fade the original content + finalColor *= collapse; + + } + + gl_FragColor = vec4(finalColor, 1.0); + } + `; + + // Create and compile shaders + const vertexShader = this.createShader(this.gl.VERTEX_SHADER, vertexShaderSource); + const fragmentShader = this.createShader(this.gl.FRAGMENT_SHADER, fragmentShaderSource); + + if (!vertexShader || !fragmentShader) return; + + // Create program + this.program = this.gl.createProgram(); + this.gl.attachShader(this.program, vertexShader); + this.gl.attachShader(this.program, fragmentShader); + this.gl.linkProgram(this.program); + + if (!this.gl.getProgramParameter(this.program, this.gl.LINK_STATUS)) { + console.error('Unable to initialize the shader program:', this.gl.getProgramInfoLog(this.program)); + return; + } + + // Set up geometry (full screen quad) + const positions = new Float32Array([ + -1, -1, + 1, -1, + -1, 1, + 1, 1 + ]); + + const positionBuffer = this.gl.createBuffer(); + this.gl.bindBuffer(this.gl.ARRAY_BUFFER, positionBuffer); + this.gl.bufferData(this.gl.ARRAY_BUFFER, positions, this.gl.STATIC_DRAW); + + const positionLocation = this.gl.getAttribLocation(this.program, 'a_position'); + this.gl.enableVertexAttribArray(positionLocation); + this.gl.vertexAttribPointer(positionLocation, 2, this.gl.FLOAT, false, 0, 0); + + // Get uniform locations + this.uniforms = { + time: this.gl.getUniformLocation(this.program, 'u_time'), + progress: this.gl.getUniformLocation(this.program, 'u_progress'), + resolution: this.gl.getUniformLocation(this.program, 'u_resolution') + }; + } + + createShader(type, source) { + const shader = this.gl.createShader(type); + this.gl.shaderSource(shader, source); + this.gl.compileShader(shader); + + if (!this.gl.getShaderParameter(shader, this.gl.COMPILE_STATUS)) { + console.error('An error occurred compiling the shaders:', this.gl.getShaderInfoLog(shader)); + this.gl.deleteShader(shader); + return null; + } + + return shader; + } + + render(progress, remainingTime) { + this.animationFrame++; + + // Clear main canvas with deep black + this.ctx.fillStyle = '#000000'; + this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); + + if (this.gl && this.program) { + // Resize WebGL canvas if needed + if (this.glCanvas.width !== this.canvas.width || this.glCanvas.height !== this.canvas.height) { + this.glCanvas.width = this.canvas.width; + this.glCanvas.height = this.canvas.height; + this.gl.viewport(0, 0, this.glCanvas.width, this.glCanvas.height); + } + + // Clear WebGL canvas with pure black + this.gl.clearColor(0.0, 0.0, 0.0, 1.0); + this.gl.clear(this.gl.COLOR_BUFFER_BIT); + + // Use shader program + this.gl.useProgram(this.program); + + // Set uniforms + this.gl.uniform1f(this.uniforms.time, this.animationFrame * 0.01); + this.gl.uniform1f(this.uniforms.progress, progress); + this.gl.uniform2f(this.uniforms.resolution, this.glCanvas.width, this.glCanvas.height); + + // Draw + this.gl.drawArrays(this.gl.TRIANGLE_STRIP, 0, 4); + + // Copy WebGL canvas to main canvas + this.ctx.drawImage(this.glCanvas, 0, 0); + } + + // Draw timer text on top + const minutes = Math.floor(remainingTime / 60000); + const seconds = Math.floor((remainingTime % 60000) / 1000); + const timeString = `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; + + const centerX = this.canvas.width / 2; + const centerY = this.canvas.height / 2; + + // Text shadow for better readability + this.ctx.shadowColor = 'rgba(0, 0, 0, 0.8)'; + this.ctx.shadowBlur = 10; + + this.ctx.font = 'bold 80px Arial'; + this.ctx.fillStyle = '#ffffff'; + this.ctx.textAlign = 'center'; + this.ctx.textBaseline = 'middle'; + this.ctx.fillText(timeString, centerX, centerY); + + this.ctx.shadowBlur = 0; + } + + onComplete() { + const centerX = this.canvas.width / 2; + const centerY = this.canvas.height / 2; + + if (!this.gl || !this.program) { + // Fallback + this.ctx.fillStyle = '#000000'; + this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); + this.ctx.font = 'bold 80px Arial'; + this.ctx.fillStyle = '#ffffff'; + this.ctx.textAlign = 'center'; + this.ctx.textBaseline = 'middle'; + this.ctx.fillText("TIME'S UP", centerX, centerY); + return; + } + + let completionFrame = 0; + const startTime = Date.now(); + + const animate = () => { + completionFrame++; + const elapsed = Date.now() - startTime; + const restingProgress = Math.min(elapsed / this.completionDuration, 1.0); + + // Clear to black + this.ctx.fillStyle = '#000000'; + this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); + + // Continue shader animation with gentle fade + this.gl.clearColor(0.0, 0.0, 0.0, 1.0); + this.gl.clear(this.gl.COLOR_BUFFER_BIT); + this.gl.useProgram(this.program); + + const progress = 1.0 + (restingProgress * 0.5); + // Use elapsed time for consistent animation speed + this.gl.uniform1f(this.uniforms.time, elapsed * 0.001); + this.gl.uniform1f(this.uniforms.progress, progress); + this.gl.uniform2f(this.uniforms.resolution, this.glCanvas.width, this.glCanvas.height); + this.gl.drawArrays(this.gl.TRIANGLE_STRIP, 0, 4); + + // Draw shader result with much slower fade + const shaderFade = Math.max(0, 1.0 - restingProgress); + this.ctx.save(); + this.ctx.globalAlpha = shaderFade; + this.ctx.drawImage(this.glCanvas, 0, 0); + this.ctx.restore(); + + // Calm completion indicator + const fadeIn = restingProgress; + + // Single breathing ring - tied to resting progress + const breathPhase = restingProgress * Math.PI * 4; // 2 full breaths during completion + const breathe = Math.sin(breathPhase) * 0.03 + 0.97; + this.ctx.strokeStyle = `rgba(255, 100, 100, ${fadeIn * 0.5})`; + this.ctx.lineWidth = 2; + this.ctx.beginPath(); + this.ctx.arc(centerX, centerY, 100 * breathe, 0, Math.PI * 2); + this.ctx.stroke(); + + // Soft center glow + const gradient = this.ctx.createRadialGradient(centerX, centerY, 0, centerX, centerY, 80); + gradient.addColorStop(0, `rgba(255, 150, 150, ${fadeIn * 0.2})`); + gradient.addColorStop(1, 'rgba(255, 100, 100, 0)'); + this.ctx.fillStyle = gradient; + this.ctx.fillRect(centerX - 150, centerY - 150, 300, 300); + + requestAnimationFrame(animate); + }; + animate(); + } +} \ No newline at end of file diff --git a/src/themes/textTheme.js b/src/themes/textTheme.js new file mode 100644 index 0000000..c00588b --- /dev/null +++ b/src/themes/textTheme.js @@ -0,0 +1,89 @@ +export class TextTheme { + constructor(canvas, ctx) { + this.canvas = canvas; + this.ctx = ctx; + this.completionDuration = 2400; // milliseconds to reach resting state + } + + setCompletionDuration(duration) { + this.completionDuration = duration; + } + + render(progress, remainingTime) { + const minutes = Math.floor(remainingTime / 60000); + const seconds = Math.floor((remainingTime % 60000) / 1000); + const timeString = `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; + + this.ctx.fillStyle = '#1a1a1a'; + this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); + + const fontSize = Math.min(this.canvas.width / 4, this.canvas.height / 3); + this.ctx.font = `${fontSize}px Arial`; + this.ctx.fillStyle = this.getColor(progress); + this.ctx.textAlign = 'center'; + this.ctx.textBaseline = 'middle'; + + this.ctx.fillText(timeString, this.canvas.width / 2, this.canvas.height / 2); + + const progressBarHeight = 20; + const progressBarY = this.canvas.height - 100; + const progressBarWidth = this.canvas.width * 0.8; + const progressBarX = (this.canvas.width - progressBarWidth) / 2; + + this.ctx.fillStyle = '#333'; + this.ctx.fillRect(progressBarX, progressBarY, progressBarWidth, progressBarHeight); + + this.ctx.fillStyle = this.getColor(progress); + this.ctx.fillRect(progressBarX, progressBarY, progressBarWidth * (1 - progress), progressBarHeight); + } + + getColor(progress) { + if (progress < 0.5) { + return '#4CAF50'; + } else if (progress < 0.8) { + return '#FFC107'; + } else { + return '#F44336'; + } + } + + onComplete() { + let frame = 0; + const startTime = Date.now(); + + const animate = () => { + frame++; + const elapsed = Date.now() - startTime; + const restingProgress = Math.min(elapsed / this.completionDuration, 1.0); + + this.ctx.fillStyle = '#1a1a1a'; + this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); + + const centerX = this.canvas.width / 2; + const centerY = this.canvas.height / 2; + const radius = Math.min(this.canvas.width, this.canvas.height) * 0.25; + + // Breathing completes 2 full cycles during completion duration + const breathPhase = (elapsed / this.completionDuration) * Math.PI * 4; + // Amplitude decreases as we reach resting state + const breathAmplitude = 0.05 * (1 - restingProgress * 0.5); + const breathe = Math.sin(breathPhase) * breathAmplitude + 0.95; + + // Soft glow + const gradient = this.ctx.createRadialGradient(centerX, centerY, 0, centerX, centerY, radius * 1.5); + gradient.addColorStop(0, 'rgba(244, 67, 54, 0.3)'); + gradient.addColorStop(1, 'rgba(244, 67, 54, 0)'); + this.ctx.fillStyle = gradient; + this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); + + // Main circle + this.ctx.fillStyle = 'rgba(244, 67, 54, 0.8)'; + this.ctx.beginPath(); + this.ctx.arc(centerX, centerY, radius * breathe, 0, Math.PI * 2); + this.ctx.fill(); + + requestAnimationFrame(animate); + }; + animate(); + } +} \ No newline at end of file diff --git a/webpack.config.js b/webpack.config.js new file mode 100644 index 0000000..547f2df --- /dev/null +++ b/webpack.config.js @@ -0,0 +1,52 @@ +const path = require('path'); +const HtmlWebpackPlugin = require('html-webpack-plugin'); +const CopyPlugin = require('copy-webpack-plugin'); + +module.exports = { + mode: 'development', + entry: './src/index.js', + output: { + filename: 'bundle.js', + path: path.resolve(__dirname, 'dist'), + clean: true, + }, + devServer: { + static: './dist', + hot: true, + open: true, + }, + module: { + rules: [ + { + test: /\.m?js$/, + exclude: /node_modules/, + use: { + loader: 'babel-loader', + options: { + presets: [ + ['@babel/preset-env', { + targets: { + esmodules: true + } + }] + ] + } + } + } + ], + }, + plugins: [ + new HtmlWebpackPlugin({ + template: './src/index.html', + title: 'Timer App', + }), + new CopyPlugin({ + patterns: [ + { from: 'src/styles.css', to: 'styles.css' } + ], + }), + ], + resolve: { + extensions: ['.js', '.mjs'] + } +}; \ No newline at end of file