initial commit
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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']
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user