initial commit

This commit is contained in:
2025-07-14 01:06:04 +02:00
commit 774e42e387
10 changed files with 2100 additions and 0 deletions
+26
View File
@@ -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"
}
}
+36
View File
@@ -0,0 +1,36 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Timer App</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div id="app">
<h1>Timer</h1>
<div class="timer-setup">
<input type="text" id="time-input" class="time-input" value="5:00" placeholder="0:00">
<button id="start-button" class="start-button">Start</button>
</div>
<div class="quick-timers">
<button class="quick-timer" data-minutes="1" data-seconds="0">1m</button>
<button class="quick-timer" data-minutes="2" data-seconds="0">2m</button>
<button class="quick-timer" data-minutes="3" data-seconds="0">3m</button>
<button class="quick-timer" data-minutes="5" data-seconds="0">5m</button>
<button class="quick-timer" data-minutes="10" data-seconds="0">10m</button>
<button class="quick-timer" data-minutes="15" data-seconds="0">15m</button>
<button class="quick-timer" data-minutes="20" data-seconds="0">20m</button>
<button class="quick-timer" data-minutes="25" data-seconds="0">25m</button>
<button class="quick-timer" data-minutes="30" data-seconds="0">30m</button>
<button class="quick-timer" data-minutes="45" data-seconds="0">45m</button>
</div>
<div class="theme-grid">
<!-- Theme cards will be generated dynamically -->
</div>
</div>
</body>
</html>
+470
View File
@@ -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 = `
<canvas id="timer-canvas"></canvas>
<button id="stop-button">Stop Timer</button>
<div id="completion-controls" class="completion-controls">
<button id="restart-button" class="control-button" title="Restart">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z" fill="currentColor"/>
</svg>
</button>
<button id="menu-button" class="control-button" title="Back to Menu">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z" fill="currentColor"/>
</svg>
</button>
</div>
`;
// 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();
});
+310
View File
@@ -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;
}
}
+253
View File
@@ -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();
}
}
+270
View File
@@ -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();
}
}
+275
View File
@@ -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();
}
}
+319
View File
@@ -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();
}
}
+89
View File
@@ -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();
}
}
+52
View File
@@ -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']
}
};