545 lines
15 KiB
TypeScript
545 lines
15 KiB
TypeScript
import {
|
|
customElement,
|
|
DeesElement,
|
|
type TemplateResult,
|
|
html,
|
|
property,
|
|
css,
|
|
cssManager,
|
|
state,
|
|
} from '@design.estate/dees-element';
|
|
import { zIndexLayers } from '../00zindex.js';
|
|
import { demo } from './eco-screensaver.demo.js';
|
|
|
|
declare global {
|
|
interface HTMLElementTagNameMap {
|
|
'eco-screensaver': EcoScreensaver;
|
|
}
|
|
}
|
|
|
|
// Subtle shadcn-inspired color palette
|
|
const colors = [
|
|
'hsl(0 0% 98%)', // zinc-50
|
|
'hsl(240 5% 65%)', // zinc-400
|
|
'hsl(240 4% 46%)', // zinc-500
|
|
'hsl(240 5% 34%)', // zinc-600
|
|
'hsl(217 91% 60%)', // blue-500
|
|
'hsl(142 71% 45%)', // green-500
|
|
];
|
|
|
|
@customElement('eco-screensaver')
|
|
export class EcoScreensaver extends DeesElement {
|
|
public static demo = demo;
|
|
|
|
// Instance management
|
|
public static instance: EcoScreensaver | null = null;
|
|
|
|
public static async show(): Promise<EcoScreensaver> {
|
|
if (EcoScreensaver.instance) {
|
|
EcoScreensaver.instance.active = true;
|
|
return EcoScreensaver.instance;
|
|
}
|
|
|
|
const screensaver = new EcoScreensaver();
|
|
screensaver.active = true;
|
|
document.body.appendChild(screensaver);
|
|
EcoScreensaver.instance = screensaver;
|
|
return screensaver;
|
|
}
|
|
|
|
public static hide(): void {
|
|
if (EcoScreensaver.instance) {
|
|
EcoScreensaver.instance.active = false;
|
|
}
|
|
}
|
|
|
|
public static destroy(): void {
|
|
if (EcoScreensaver.instance) {
|
|
EcoScreensaver.instance.remove();
|
|
EcoScreensaver.instance = null;
|
|
}
|
|
}
|
|
|
|
// Styles
|
|
public static styles = [
|
|
cssManager.defaultStyles,
|
|
css`
|
|
:host {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100vw;
|
|
height: 100vh;
|
|
z-index: ${zIndexLayers.overlay.screensaver};
|
|
pointer-events: none;
|
|
opacity: 0;
|
|
transition: opacity 0.3s ease;
|
|
}
|
|
|
|
:host([active]) {
|
|
pointer-events: all;
|
|
opacity: 1;
|
|
}
|
|
|
|
.backdrop {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
background: hsl(240 10% 4%);
|
|
opacity: 0;
|
|
transition: opacity 1.5s ease 0.5s;
|
|
}
|
|
|
|
:host([active]) .backdrop {
|
|
opacity: 1;
|
|
}
|
|
|
|
.vignette {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
background: radial-gradient(
|
|
circle at center,
|
|
transparent 0%,
|
|
transparent 5%,
|
|
hsla(240 10% 4% / 0.2) 20%,
|
|
hsla(240 10% 4% / 0.6) 40%,
|
|
hsla(240 10% 4% / 0.9) 60%,
|
|
hsl(240 10% 4%) 80%
|
|
);
|
|
transform: scale(3);
|
|
opacity: 0;
|
|
transition:
|
|
transform 2s cubic-bezier(0.4, 0, 0.2, 1),
|
|
opacity 0.5s ease;
|
|
}
|
|
|
|
:host([active]) .vignette {
|
|
transform: scale(1);
|
|
opacity: 1;
|
|
}
|
|
|
|
/* Container for all screensaver visuals - gets masked on dismiss */
|
|
.screensaver-content {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
|
|
.time-container {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 8px;
|
|
user-select: none;
|
|
white-space: nowrap;
|
|
will-change: transform;
|
|
opacity: 0;
|
|
transition: opacity 0.8s ease;
|
|
transition-delay: 0s;
|
|
}
|
|
|
|
:host([active]) .time-container {
|
|
opacity: 1;
|
|
transition-delay: 1.2s;
|
|
}
|
|
|
|
.time {
|
|
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
font-size: 96px;
|
|
font-weight: 200;
|
|
letter-spacing: -2px;
|
|
line-height: 1;
|
|
transition: color 1.5s ease;
|
|
}
|
|
|
|
.date {
|
|
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
font-size: 18px;
|
|
font-weight: 400;
|
|
letter-spacing: 0.5px;
|
|
opacity: 0.5;
|
|
text-transform: uppercase;
|
|
transition: color 1.5s ease;
|
|
}
|
|
|
|
@media (max-width: 600px) {
|
|
.time {
|
|
font-size: 48px;
|
|
letter-spacing: -1px;
|
|
}
|
|
|
|
.date {
|
|
font-size: 14px;
|
|
}
|
|
}
|
|
|
|
.hint {
|
|
position: fixed;
|
|
bottom: 32px;
|
|
left: 50%;
|
|
transform: translateX(-50%) translateY(20px);
|
|
padding: 12px 24px;
|
|
background: hsl(240 6% 15%);
|
|
border: 1px solid hsl(240 5% 26%);
|
|
border-radius: 8px;
|
|
color: hsl(0 0% 90%);
|
|
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
transition: opacity 0.3s ease, transform 0.3s ease;
|
|
z-index: 10;
|
|
}
|
|
|
|
.hint.visible {
|
|
opacity: 1;
|
|
transform: translateX(-50%) translateY(0);
|
|
}
|
|
`,
|
|
];
|
|
|
|
@property({ type: Boolean, reflect: true })
|
|
accessor active = false;
|
|
|
|
@property({ type: Number })
|
|
accessor delay = 0; // milliseconds before activation (0 = no delay)
|
|
|
|
@state()
|
|
accessor currentTime = '';
|
|
|
|
@state()
|
|
accessor currentDate = '';
|
|
|
|
@state()
|
|
accessor currentColor = colors[0];
|
|
|
|
// Animation state - non-reactive for smooth animation
|
|
private posX = 100;
|
|
private posY = 100;
|
|
private velocityX = 0.3;
|
|
private velocityY = 0.2;
|
|
private animationId: number | null = null;
|
|
private timeUpdateInterval: ReturnType<typeof setInterval> | null = null;
|
|
private colorIndex = 0;
|
|
private elementWidth = 280;
|
|
private elementHeight = 140;
|
|
private hasBounced = false;
|
|
private timeContainerEl: HTMLElement | null = null;
|
|
private vignetteEl: HTMLElement | null = null;
|
|
private contentEl: HTMLElement | null = null;
|
|
private delayTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
private boundResetDelayTimer: () => void;
|
|
private boundShowHint: () => void;
|
|
private hintEl: HTMLElement | null = null;
|
|
private hintTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
private hintVisible = false;
|
|
|
|
constructor() {
|
|
super();
|
|
this.updateTime();
|
|
this.boundResetDelayTimer = this.resetDelayTimer.bind(this);
|
|
this.boundShowHint = this.showHint.bind(this);
|
|
}
|
|
|
|
public render(): TemplateResult {
|
|
return html`
|
|
<div class="screensaver-content" @click=${this.handleClick}>
|
|
<div class="backdrop"></div>
|
|
<div class="vignette"></div>
|
|
<div class="time-container">
|
|
<span class="time" style="color: ${this.currentColor};">${this.currentTime}</span>
|
|
<span class="date" style="color: ${this.currentColor};">${this.currentDate}</span>
|
|
</div>
|
|
</div>
|
|
<div class="hint">Click to exit screensaver</div>
|
|
`;
|
|
}
|
|
|
|
public firstUpdated(): void {
|
|
this.timeContainerEl = this.shadowRoot?.querySelector('.time-container') as HTMLElement;
|
|
this.vignetteEl = this.shadowRoot?.querySelector('.vignette') as HTMLElement;
|
|
this.contentEl = this.shadowRoot?.querySelector('.screensaver-content') as HTMLElement;
|
|
this.hintEl = this.shadowRoot?.querySelector('.hint') as HTMLElement;
|
|
}
|
|
|
|
async connectedCallback(): Promise<void> {
|
|
await super.connectedCallback();
|
|
|
|
// If delay is set, start the delay timer and listen for activity
|
|
if (this.delay > 0 && !this.active) {
|
|
this.startDelayTimer();
|
|
this.addActivityListeners();
|
|
} else if (this.active) {
|
|
this.startAnimation();
|
|
this.startTimeUpdate();
|
|
}
|
|
}
|
|
|
|
async disconnectedCallback(): Promise<void> {
|
|
await super.disconnectedCallback();
|
|
this.stopAnimation();
|
|
this.stopTimeUpdate();
|
|
this.stopDelayTimer();
|
|
this.removeActivityListeners();
|
|
}
|
|
|
|
updated(changedProperties: Map<string, unknown>): void {
|
|
super.updated(changedProperties);
|
|
if (changedProperties.has('active')) {
|
|
if (this.active) {
|
|
// Reset mask for fresh activation
|
|
if (this.contentEl) {
|
|
this.contentEl.style.maskImage = '';
|
|
this.contentEl.style.webkitMaskImage = '';
|
|
}
|
|
// Hide hint when freshly activated
|
|
this.hideHint();
|
|
// Listen for mouse movement to show hint
|
|
window.addEventListener('mousemove', this.boundShowHint);
|
|
this.startAnimation();
|
|
this.startTimeUpdate();
|
|
} else {
|
|
window.removeEventListener('mousemove', this.boundShowHint);
|
|
this.hideHint();
|
|
this.stopAnimation();
|
|
this.stopTimeUpdate();
|
|
}
|
|
}
|
|
}
|
|
|
|
private updateTime(): void {
|
|
const now = new Date();
|
|
const hours = String(now.getHours()).padStart(2, '0');
|
|
const minutes = String(now.getMinutes()).padStart(2, '0');
|
|
this.currentTime = `${hours}:${minutes}`;
|
|
|
|
// Format date like "Monday, January 6"
|
|
const options: Intl.DateTimeFormatOptions = {
|
|
weekday: 'long',
|
|
month: 'long',
|
|
day: 'numeric',
|
|
};
|
|
this.currentDate = now.toLocaleDateString('en-US', options);
|
|
}
|
|
|
|
private startTimeUpdate(): void {
|
|
if (this.timeUpdateInterval) return;
|
|
this.updateTime();
|
|
this.timeUpdateInterval = setInterval(() => this.updateTime(), 1000);
|
|
}
|
|
|
|
private stopTimeUpdate(): void {
|
|
if (this.timeUpdateInterval) {
|
|
clearInterval(this.timeUpdateInterval);
|
|
this.timeUpdateInterval = null;
|
|
}
|
|
}
|
|
|
|
private startAnimation(): void {
|
|
if (this.animationId) return;
|
|
|
|
// Initialize position randomly
|
|
const maxX = window.innerWidth - this.elementWidth;
|
|
const maxY = window.innerHeight - this.elementHeight;
|
|
this.posX = Math.random() * Math.max(0, maxX);
|
|
this.posY = Math.random() * Math.max(0, maxY);
|
|
|
|
// Randomize initial direction - very slow, elegant movement
|
|
this.velocityX = (Math.random() > 0.5 ? 1 : -1) * (0.2 + Math.random() * 0.15);
|
|
this.velocityY = (Math.random() > 0.5 ? 1 : -1) * (0.15 + Math.random() * 0.1);
|
|
|
|
// Reset bounce state
|
|
this.hasBounced = false;
|
|
|
|
const animate = () => {
|
|
if (!this.active) {
|
|
this.animationId = null;
|
|
return;
|
|
}
|
|
|
|
const maxX = window.innerWidth - this.elementWidth;
|
|
const maxY = window.innerHeight - this.elementHeight;
|
|
|
|
// Update position
|
|
this.posX += this.velocityX;
|
|
this.posY += this.velocityY;
|
|
|
|
// Track if we're currently at a boundary
|
|
let atBoundary = false;
|
|
|
|
// Bounce off walls
|
|
if (this.posX <= 0) {
|
|
this.posX = 0;
|
|
this.velocityX = Math.abs(this.velocityX);
|
|
atBoundary = true;
|
|
} else if (this.posX >= maxX) {
|
|
this.posX = maxX;
|
|
this.velocityX = -Math.abs(this.velocityX);
|
|
atBoundary = true;
|
|
}
|
|
|
|
if (this.posY <= 0) {
|
|
this.posY = 0;
|
|
this.velocityY = Math.abs(this.velocityY);
|
|
atBoundary = true;
|
|
} else if (this.posY >= maxY) {
|
|
this.posY = maxY;
|
|
this.velocityY = -Math.abs(this.velocityY);
|
|
atBoundary = true;
|
|
}
|
|
|
|
// Change color only once per bounce (when entering boundary, not while at it)
|
|
if (atBoundary && !this.hasBounced) {
|
|
this.hasBounced = true;
|
|
this.colorIndex = (this.colorIndex + 1) % colors.length;
|
|
this.currentColor = colors[this.colorIndex];
|
|
} else if (!atBoundary) {
|
|
this.hasBounced = false;
|
|
}
|
|
|
|
// Direct DOM manipulation for smooth position updates (no re-render)
|
|
if (this.timeContainerEl) {
|
|
this.timeContainerEl.style.transform = `translate(${this.posX}px, ${this.posY}px)`;
|
|
}
|
|
|
|
this.animationId = requestAnimationFrame(animate);
|
|
};
|
|
|
|
this.animationId = requestAnimationFrame(animate);
|
|
}
|
|
|
|
private stopAnimation(): void {
|
|
if (this.animationId) {
|
|
cancelAnimationFrame(this.animationId);
|
|
this.animationId = null;
|
|
}
|
|
}
|
|
|
|
private startDelayTimer(): void {
|
|
this.stopDelayTimer();
|
|
this.delayTimeoutId = setTimeout(() => {
|
|
this.removeActivityListeners();
|
|
this.active = true;
|
|
}, this.delay);
|
|
}
|
|
|
|
private stopDelayTimer(): void {
|
|
if (this.delayTimeoutId) {
|
|
clearTimeout(this.delayTimeoutId);
|
|
this.delayTimeoutId = null;
|
|
}
|
|
}
|
|
|
|
private resetDelayTimer(): void {
|
|
if (this.delay > 0 && !this.active) {
|
|
this.startDelayTimer();
|
|
}
|
|
}
|
|
|
|
private addActivityListeners(): void {
|
|
window.addEventListener('mousemove', this.boundResetDelayTimer);
|
|
window.addEventListener('keydown', this.boundResetDelayTimer);
|
|
window.addEventListener('click', this.boundResetDelayTimer);
|
|
window.addEventListener('touchstart', this.boundResetDelayTimer);
|
|
window.addEventListener('scroll', this.boundResetDelayTimer);
|
|
}
|
|
|
|
private removeActivityListeners(): void {
|
|
window.removeEventListener('mousemove', this.boundResetDelayTimer);
|
|
window.removeEventListener('keydown', this.boundResetDelayTimer);
|
|
window.removeEventListener('click', this.boundResetDelayTimer);
|
|
window.removeEventListener('touchstart', this.boundResetDelayTimer);
|
|
window.removeEventListener('scroll', this.boundResetDelayTimer);
|
|
}
|
|
|
|
private showHint(): void {
|
|
if (!this.active || this.hintVisible) return;
|
|
|
|
this.hintVisible = true;
|
|
if (this.hintEl) {
|
|
this.hintEl.classList.add('visible');
|
|
}
|
|
|
|
// Auto-hide after 3 seconds
|
|
if (this.hintTimeoutId) {
|
|
clearTimeout(this.hintTimeoutId);
|
|
}
|
|
this.hintTimeoutId = setTimeout(() => {
|
|
this.hideHint();
|
|
}, 3000);
|
|
}
|
|
|
|
private hideHint(): void {
|
|
this.hintVisible = false;
|
|
if (this.hintEl) {
|
|
this.hintEl.classList.remove('visible');
|
|
}
|
|
if (this.hintTimeoutId) {
|
|
clearTimeout(this.hintTimeoutId);
|
|
this.hintTimeoutId = null;
|
|
}
|
|
}
|
|
|
|
private handleClick(event: MouseEvent | TouchEvent): void {
|
|
// Get click/touch position
|
|
let x: number, y: number;
|
|
|
|
if (event instanceof TouchEvent && event.changedTouches.length > 0) {
|
|
x = event.changedTouches[0].clientX;
|
|
y = event.changedTouches[0].clientY;
|
|
} else if (event instanceof MouseEvent) {
|
|
x = event.clientX;
|
|
y = event.clientY;
|
|
} else {
|
|
// Fallback to center
|
|
x = window.innerWidth / 2;
|
|
y = window.innerHeight / 2;
|
|
}
|
|
|
|
this.dispatchEvent(new CustomEvent('screensaver-click', { detail: { x, y } }));
|
|
|
|
// Animate circle reveal from click position
|
|
if (this.contentEl) {
|
|
const duration = 700; // ms - slower for dramatic effect
|
|
const startTime = performance.now();
|
|
const maxSize = Math.max(window.innerWidth, window.innerHeight) * 1.5;
|
|
const startSize = 20; // Start with small visible circle
|
|
|
|
const animate = (currentTime: number) => {
|
|
const elapsed = currentTime - startTime;
|
|
const progress = Math.min(elapsed / duration, 1);
|
|
|
|
// Ease-in curve: starts slow, picks up speed
|
|
const eased = progress * progress * progress;
|
|
const size = startSize + eased * (maxSize - startSize);
|
|
|
|
this.contentEl!.style.maskImage = `radial-gradient(circle ${size}px at ${x}px ${y}px, transparent 100%, black 100%)`;
|
|
this.contentEl!.style.webkitMaskImage = `radial-gradient(circle ${size}px at ${x}px ${y}px, transparent 100%, black 100%)`;
|
|
|
|
if (progress < 1) {
|
|
requestAnimationFrame(animate);
|
|
} else {
|
|
// Animation complete - remove screensaver
|
|
this.active = false;
|
|
EcoScreensaver.destroy();
|
|
}
|
|
};
|
|
|
|
requestAnimationFrame(animate);
|
|
} else {
|
|
this.active = false;
|
|
}
|
|
}
|
|
}
|