diff --git a/.claude/agent-memory/researcher/MEMORY.md b/.claude/agent-memory/researcher/MEMORY.md new file mode 100644 index 0000000..6d9f7fb --- /dev/null +++ b/.claude/agent-memory/researcher/MEMORY.md @@ -0,0 +1 @@ +- [State-sharing feature context](project_state_sharing.md) — goal, key state fields, existing AWS infra, no user-auth today diff --git a/.claude/agent-memory/researcher/project_state_sharing.md b/.claude/agent-memory/researcher/project_state_sharing.md new file mode 100644 index 0000000..505617d --- /dev/null +++ b/.claude/agent-memory/researcher/project_state_sharing.md @@ -0,0 +1,18 @@ +--- +name: state-sharing feature context +description: Goal and codebase context for the state-sharing feature branch — syncing favorites/heard state across devices via username/password encrypted cloud backend +type: project +--- + +State-sharing feature goal: sync user state (favorites, heard tracks) across devices using a username/password that encrypts data stored in a cloud backend. Work is on the `state-sharing` branch. + +**Why:** Currently all state is device-local (localStorage + IndexedDB). Users lose state when switching devices or browsers. + +**How to apply:** Any new backend must integrate with the existing `storage.js` load/save pattern. The sync model should be additive — localStorage remains the source of truth locally, cloud is the sync layer. + +Key state to sync: +- `state.favoriteTracks` (Set of track IDs) — stored in localStorage under `${prefix}_favorite_tracks` +- `state.heardTracks` (Set of track IDs) — stored in localStorage under `${prefix}_heard_tracks` +- `state.secretUnlocked` (boolean) — stored under `${prefix}_secret_unlocked` + +Existing AWS infra: S3 + CloudFront + Route53 + Secrets Manager, managed by Terraform in `/terraform/`. No existing Lambda, API Gateway, Cognito, or DynamoDB. Auth today is CloudFront signed cookies (not user-level auth). diff --git a/.claude/plans/magical-petting-snowglobe-agent-ac1ef825dc8ba1c46.md b/.claude/plans/magical-petting-snowglobe-agent-ac1ef825dc8ba1c46.md new file mode 100644 index 0000000..a8921f1 --- /dev/null +++ b/.claude/plans/magical-petting-snowglobe-agent-ac1ef825dc8ba1c46.md @@ -0,0 +1,1303 @@ +# State-Sharing Implementation Plan + +## Architecture Overview + +``` +Browser CloudFront Lambda S3 + | | | | + |-- PUT /sync/{username} --------->|-- /sync/* behavior ------>|-- PutObject --------->| + | (encrypted blob + write_hash) | (Lambda Function URL) | sync/{user}.json | + | | | | + |-- GET /sync/{username} --------->|-------------------------->|-- GetObject --------->| + | (returns ciphertext) | | | +``` + +**Client-side encryption flow:** +``` +password --> PBKDF2(password, username_salt, 100k iters) --> 256-bit AES key +state JSON --> AES-GCM encrypt(key, iv) --> base64 ciphertext +write_hash = SHA-256(password + username) --> sent alongside blob for write auth +``` + +--- + +## Task Decomposition (5 parallelizable work units) + +| # | Task | Deps | Parallelizable With | +|---|------|------|---------------------| +| T1 | Terraform: Lambda + CloudFront behavior + IAM | None | T2, T3, T4 | +| T2 | Lambda handler code | None | T1, T3, T4 | +| T3 | Client: crypto module + sync module | None | T1, T2, T4 | +| T4 | Client: UI (modal, button, animations, CSS) | None | T1, T2, T3 | +| T5 | Client: wiring + offline bug fix | T3, T4 | None | + +--- + +## T1: Terraform Infrastructure + +### New file: `terraform/lambda-sync.tf` + +```hcl +# Lambda function for state sync read/write +resource "aws_lambda_function" "sync" { + function_name = "${var.subdomain}-state-sync" + runtime = "nodejs20.x" + handler = "index.handler" + timeout = 10 + memory_size = 128 + + filename = "${path.module}/lambda/sync.zip" + source_code_hash = filebase64sha256("${path.module}/lambda/sync.zip") + + role = aws_iam_role.sync_lambda.arn + + environment { + variables = { + BUCKET_NAME = aws_s3_bucket.tracks.id + SYNC_PREFIX = "sync/" + } + } +} + +# Lambda Function URL (no API Gateway needed) +resource "aws_lambda_function_url" "sync" { + function_name = aws_lambda_function.sync.function_name + authorization_type = "NONE" + + cors { + allow_origins = ["https://${local.domain_name}"] + allow_methods = ["GET", "PUT"] + allow_headers = ["Content-Type"] + max_age = 3600 + } +} + +# IAM role for Lambda +resource "aws_iam_role" "sync_lambda" { + name = "${var.subdomain}-sync-lambda-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { Service = "lambda.amazonaws.com" } + }] + }) +} + +# Lambda basic execution (CloudWatch logs) +resource "aws_iam_role_policy_attachment" "sync_lambda_logs" { + role = aws_iam_role.sync_lambda.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" +} + +# S3 access for sync/ prefix only +resource "aws_iam_role_policy" "sync_lambda_s3" { + name = "${var.subdomain}-sync-lambda-s3" + role = aws_iam_role.sync_lambda.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = ["s3:GetObject", "s3:PutObject"] + Resource = "${aws_s3_bucket.tracks.arn}/sync/*" + }] + }) +} +``` + +### Modified file: `terraform/cloudfront.tf` + +Add new ordered_cache_behavior BEFORE the audio behavior (line ~98), and a new Lambda origin: + +```hcl +# New origin: Lambda Function URL for sync +origin { + domain_name = replace(replace(aws_lambda_function_url.sync.function_url, "https://", ""), "/", "") + origin_id = "sync-lambda" + + custom_origin_config { + http_port = 80 + https_port = 443 + origin_protocol_policy = "https-only" + origin_ssl_protocols = ["TLSv1.2"] + } +} + +# Sync API behavior: Lambda Function URL +ordered_cache_behavior { + path_pattern = "/sync/*" + allowed_methods = ["GET", "HEAD", "OPTIONS", "PUT", "POST", "PATCH", "DELETE"] + cached_methods = ["GET", "HEAD"] + target_origin_id = "sync-lambda" + viewer_protocol_policy = "redirect-to-https" + compress = true + + # No caching for sync requests + min_ttl = 0 + default_ttl = 0 + max_ttl = 0 + + forwarded_values { + query_string = false + headers = ["Origin", "Access-Control-Request-Method", "Access-Control-Request-Headers"] + cookies { + forward = "none" + } + } +} +``` + +### Modified file: `terraform/outputs.tf` + +Add: +```hcl +output "sync_lambda_url" { + description = "Lambda Function URL for state sync" + value = aws_lambda_function_url.sync.function_url +} +``` + +### New directory: `terraform/lambda/` + +Lambda code lives here, zipped for deployment (see T2). + +--- + +## T2: Lambda Handler + +### New file: `terraform/lambda/index.mjs` + +```javascript +import { S3Client, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3'; + +const s3 = new S3Client({}); +const BUCKET = process.env.BUCKET_NAME; +const PREFIX = process.env.SYNC_PREFIX || 'sync/'; + +export async function handler(event) { + const method = event.requestContext?.http?.method || event.httpMethod; + // Extract username from path: /sync/{username} + const path = event.rawPath || event.path || ''; + const username = path.replace(/^\/sync\//, '').replace(/\.json$/, ''); + + if (!username || username.includes('/') || username.includes('..')) { + return respond(400, { error: 'Invalid username' }); + } + + const key = `${PREFIX}${username}.json`; + + if (method === 'GET') { + return handleGet(key); + } else if (method === 'PUT') { + return handlePut(key, event); + } else { + return respond(405, { error: 'Method not allowed' }); + } +} + +async function handleGet(key) { + try { + const result = await s3.send(new GetObjectCommand({ Bucket: BUCKET, Key: key })); + const body = await result.Body.transformToString(); + return respond(200, JSON.parse(body)); + } catch (e) { + if (e.name === 'NoSuchKey') { + return respond(404, { error: 'No sync data found' }); + } + console.error('GET error:', e); + return respond(500, { error: 'Internal error' }); + } +} + +async function handlePut(key, event) { + let body; + try { + body = JSON.parse(event.body); + } catch { + return respond(400, { error: 'Invalid JSON' }); + } + + const { ciphertext, iv, salt, write_hash } = body; + if (!ciphertext || !iv || !salt || !write_hash) { + return respond(400, { error: 'Missing required fields: ciphertext, iv, salt, write_hash' }); + } + + // Check existing write_hash (if file exists, must match) + try { + const existing = await s3.send(new GetObjectCommand({ Bucket: BUCKET, Key: key })); + const existingData = JSON.parse(await existing.Body.transformToString()); + if (existingData.write_hash && existingData.write_hash !== write_hash) { + return respond(403, { error: 'Invalid credentials' }); + } + } catch (e) { + if (e.name !== 'NoSuchKey') { + console.error('Auth check error:', e); + return respond(500, { error: 'Internal error' }); + } + // NoSuchKey = new user, allow creation + } + + // Write the blob + const record = { + ciphertext, + iv, + salt, + write_hash, + updated_at: new Date().toISOString() + }; + + await s3.send(new PutObjectCommand({ + Bucket: BUCKET, + Key: key, + Body: JSON.stringify(record), + ContentType: 'application/json' + })); + + return respond(200, { ok: true, updated_at: record.updated_at }); +} + +function respond(status, body) { + return { + statusCode: status, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }; +} +``` + +**Build step:** `cd terraform/lambda && zip sync.zip index.mjs` (no node_modules needed — AWS SDK v3 is built into nodejs20.x runtime). + +--- + +## T3: Client Crypto + Sync Module + +### New file: `www/js/crypto.js` + +Encryption and hashing using only Web Crypto API (no dependencies). + +```javascript +/** + * Client-side encryption for state sync + * Uses PBKDF2 for key derivation and AES-GCM for encryption + * @module crypto + */ + +const PBKDF2_ITERATIONS = 100000; +const KEY_LENGTH = 256; + +/** + * Derive an AES-GCM key from password + username + * @param {string} password + * @param {string} username - used as salt + * @returns {Promise} + */ +export async function deriveKey(password, username) { + const enc = new TextEncoder(); + const keyMaterial = await crypto.subtle.importKey( + 'raw', enc.encode(password), 'PBKDF2', false, ['deriveKey'] + ); + return crypto.subtle.deriveKey( + { name: 'PBKDF2', salt: enc.encode(username), iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' }, + keyMaterial, + { name: 'AES-GCM', length: KEY_LENGTH }, + false, + ['encrypt', 'decrypt'] + ); +} + +/** + * Encrypt plaintext JSON string with AES-GCM + * @param {CryptoKey} key + * @param {string} plaintext + * @returns {Promise<{ciphertext: string, iv: string}>} base64-encoded + */ +export async function encrypt(key, plaintext) { + const enc = new TextEncoder(); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const encrypted = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + key, enc.encode(plaintext) + ); + return { + ciphertext: btoa(String.fromCharCode(...new Uint8Array(encrypted))), + iv: btoa(String.fromCharCode(...iv)) + }; +} + +/** + * Decrypt ciphertext with AES-GCM + * @param {CryptoKey} key + * @param {string} ciphertextB64 - base64 ciphertext + * @param {string} ivB64 - base64 IV + * @returns {Promise} decrypted plaintext + */ +export async function decrypt(key, ciphertextB64, ivB64) { + const ciphertext = Uint8Array.from(atob(ciphertextB64), c => c.charCodeAt(0)); + const iv = Uint8Array.from(atob(ivB64), c => c.charCodeAt(0)); + const decrypted = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv }, + key, ciphertext + ); + return new TextDecoder().decode(decrypted); +} + +/** + * Generate write_hash = SHA-256(password + username) for write authorization + * @param {string} password + * @param {string} username + * @returns {Promise} hex-encoded hash + */ +export async function generateWriteHash(password, username) { + const enc = new TextEncoder(); + const hash = await crypto.subtle.digest('SHA-256', enc.encode(password + username)); + return Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, '0')).join(''); +} +``` + +### New file: `www/js/sync.js` + +State sync orchestration — push/pull/merge logic. + +```javascript +/** + * State sync — push/pull encrypted state to cloud + * @module sync + */ + +import { state } from './state.js'; +import { saveFavoriteTracks, saveHeardTracks, setSecretUnlocked } from './storage.js'; +import { deriveKey, encrypt, decrypt, generateWriteHash } from './crypto.js'; +import { trackEvent } from './analytics.js'; + +const SYNC_ENDPOINT = '/sync'; +const SYNC_CREDS_KEY = 'crate_sync_credentials'; + +/** + * Store sync credentials in localStorage (username only, not password) + */ +export function saveSyncUsername(username) { + try { + localStorage.setItem(SYNC_CREDS_KEY, JSON.stringify({ username })); + } catch (e) { /* ignore */ } +} + +/** + * Get stored sync username + * @returns {string|null} + */ +export function getSyncUsername() { + try { + const stored = localStorage.getItem(SYNC_CREDS_KEY); + if (stored) return JSON.parse(stored).username; + } catch (e) { /* ignore */ } + return null; +} + +/** + * Clear sync credentials + */ +export function clearSyncCredentials() { + try { localStorage.removeItem(SYNC_CREDS_KEY); } catch (e) { /* ignore */ } +} + +/** + * Serialize current state to JSON for encryption + * @returns {string} + */ +function serializeState() { + return JSON.stringify({ + favoriteTracks: [...state.favoriteTracks], + heardTracks: [...state.heardTracks], + secretUnlocked: state.secretUnlocked, + syncedAt: new Date().toISOString() + }); +} + +/** + * Merge remote state into local state (union merge) + * @param {Object} remote - Deserialized remote state + * @returns {{favoritesAdded: number, heardAdded: number, secretChanged: boolean}} + */ +function mergeState(remote) { + let favoritesAdded = 0; + let heardAdded = 0; + let secretChanged = false; + + // Favorites: union merge + if (Array.isArray(remote.favoriteTracks)) { + for (const id of remote.favoriteTracks) { + if (!state.favoriteTracks.has(id)) { + state.favoriteTracks.add(id); + favoritesAdded++; + } + } + saveFavoriteTracks(); + } + + // Heard: union merge (never un-hear) + if (Array.isArray(remote.heardTracks)) { + for (const id of remote.heardTracks) { + if (!state.heardTracks.has(id)) { + state.heardTracks.add(id); + heardAdded++; + } + } + saveHeardTracks(); + } + + // Secret: OR (once unlocked, stays unlocked) + if (remote.secretUnlocked && !state.secretUnlocked) { + state.secretUnlocked = true; + state.mode = 'secret'; + setSecretUnlocked(true); + secretChanged = true; + } + + return { favoritesAdded, heardAdded, secretChanged }; +} + +/** + * Pull state from server and merge + * @param {string} username + * @param {string} password + * @returns {Promise<{status: 'merged'|'empty'|'error', details?: Object, error?: string}>} + */ +export async function pullState(username, password) { + try { + const response = await fetch(`${SYNC_ENDPOINT}/${encodeURIComponent(username)}`); + + if (response.status === 404) { + return { status: 'empty' }; + } + if (!response.ok) { + return { status: 'error', error: `Server error: ${response.status}` }; + } + + const data = await response.json(); + const key = await deriveKey(password, username); + + let plaintext; + try { + plaintext = await decrypt(key, data.ciphertext, data.iv); + } catch (e) { + return { status: 'error', error: 'Wrong password — could not decrypt' }; + } + + const remote = JSON.parse(plaintext); + const details = mergeState(remote); + + trackEvent('sync_pull', { + favorites_added: details.favoritesAdded, + heard_added: details.heardAdded, + secret_changed: details.secretChanged + }); + + return { status: 'merged', details }; + } catch (e) { + console.error('Pull failed:', e); + return { status: 'error', error: e.message }; + } +} + +/** + * Push current state to server (full replace) + * @param {string} username + * @param {string} password + * @returns {Promise<{status: 'ok'|'error', error?: string}>} + */ +export async function pushState(username, password) { + try { + const key = await deriveKey(password, username); + const plaintext = serializeState(); + const { ciphertext, iv } = await encrypt(key, plaintext); + const write_hash = await generateWriteHash(password, username); + + const response = await fetch(`${SYNC_ENDPOINT}/${encodeURIComponent(username)}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ciphertext, iv, salt: username, write_hash }) + }); + + if (!response.ok) { + const err = await response.json().catch(() => ({})); + return { status: 'error', error: err.error || `Server error: ${response.status}` }; + } + + trackEvent('sync_push', { + favorites: state.favoriteTracks.size, + heard: state.heardTracks.size + }); + + return { status: 'ok' }; + } catch (e) { + console.error('Push failed:', e); + return { status: 'error', error: e.message }; + } +} + +/** + * Full sync: pull (merge), then push (replace) + * @param {string} username + * @param {string} password + * @returns {Promise<{status: 'ok'|'error', pullResult?: Object, error?: string}>} + */ +export async function fullSync(username, password) { + const pullResult = await pullState(username, password); + if (pullResult.status === 'error') return pullResult; + + const pushResult = await pushState(username, password); + if (pushResult.status === 'error') return pushResult; + + return { status: 'ok', pullResult }; +} +``` + +--- + +## T4: UI — Modal, Button, Animations, CSS + +### Modified file: `www/index.html` + +**1. Rename existing sync button** (line 152): + +```html + + + + + + +``` + +**2. Add sync modal** (after `#info-modal`, before ``): + +```html + + +``` + +### Modified file: `www/main.css` + +**New CSS additions** (add after the existing `.sync-progress-text` block, ~line 751): + +```css +/* ============================================ + State Sync UI + ============================================ */ + +/* Sync button — two rotating arrows icon */ +.state-sync-btn { + position: relative; +} + +.state-sync-btn svg { + width: 20px; + height: 20px; + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.state-sync-btn:hover svg { + transform: rotate(30deg); +} + +.state-sync-btn:active svg { + transform: rotate(180deg); + transition-duration: 0.15s; +} + +/* Syncing state — continuous rotation */ +.state-sync-btn.syncing svg { + animation: sync-spin 0.8s cubic-bezier(0.4, 0, 0.2, 1) infinite; +} + +/* Synced indicator — green dot */ +.state-sync-btn.connected::after { + content: ''; + position: absolute; + bottom: 4px; + right: 4px; + width: 6px; + height: 6px; + background: #4a4; + border: 1px solid var(--bg); +} + +/* Success flash */ +.state-sync-btn.sync-success { + color: #4a4; + border-color: #4a4; +} + +.state-sync-btn.sync-error { + color: var(--accent); + border-color: var(--accent); +} + +@keyframes sync-spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +@keyframes sync-success-flash { + 0% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.7; transform: scale(1.15); } + 100% { opacity: 1; transform: scale(1); } +} + +/* ---- Sync Modal ---- */ +.sync-modal-content { + max-width: 360px; + padding: 40px 30px; + text-align: center; + /* Slide up from below */ + animation: sync-modal-enter 0.35s cubic-bezier(0.2, 0.8, 0.2, 1) both; +} + +@keyframes sync-modal-enter { + from { + opacity: 0; + transform: translateY(40px) scale(0.95); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +/* Backdrop fade */ +.sync-modal-content ~ .modal-backdrop, +#sync-modal .modal-backdrop { + animation: sync-backdrop-in 0.25s ease-out both; +} + +@keyframes sync-backdrop-in { + from { opacity: 0; } + to { opacity: 1; } +} + +.sync-modal-title { + font-family: 'Anton', Impact, sans-serif; + font-size: 32px; + letter-spacing: 0.15em; + color: var(--fg); + margin: 0 0 5px; +} + +.sync-modal-subtitle { + font-family: 'Special Elite', cursive; + font-size: 13px; + color: var(--muted); + margin: 0 0 30px; +} + +/* Form inputs */ +.sync-input { + width: 100%; + padding: 12px 15px; + background: transparent; + border: 1px solid var(--muted); + color: var(--fg); + font-family: 'Bebas Neue', sans-serif; + font-size: 16px; + letter-spacing: 0.1em; + margin-bottom: 12px; + outline: none; + transition: border-color 0.2s ease-out; + box-sizing: border-box; + -webkit-appearance: none; +} + +.sync-input:focus { + border-color: var(--fg); +} + +.sync-input::placeholder { + color: var(--muted); + opacity: 0.6; +} + +/* Error message */ +.sync-error { + color: var(--accent); + font-family: 'Special Elite', cursive; + font-size: 12px; + margin-bottom: 12px; + text-align: left; +} + +/* Action buttons */ +.sync-actions { + display: flex; + gap: 10px; + margin-top: 8px; +} + +.sync-action-btn { + flex: 1; + padding: 12px; + background: transparent; + border: 2px solid var(--fg); + color: var(--fg); + font-family: 'Anton', Impact, sans-serif; + font-size: 16px; + letter-spacing: 0.15em; + cursor: pointer; + transition: all 0.15s ease-out; +} + +.sync-action-btn:hover { + background: var(--fg); + color: var(--bg); +} + +.sync-action-btn:active { + transform: scale(0.97); +} + +.sync-action-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +/* Status indicator */ +.sync-status { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + margin-top: 20px; + font-family: 'Special Elite', cursive; + font-size: 13px; +} + +.sync-status-icon { + width: 16px; + height: 16px; + border: 2px solid var(--muted); + animation: sync-spin 0.8s cubic-bezier(0.4, 0, 0.2, 1) infinite; +} + +.sync-status.success .sync-status-icon { + border-color: #4a4; + background: #4a4; + animation: sync-success-flash 0.4s cubic-bezier(0.2, 0.8, 0.2, 1); +} + +.sync-status.error .sync-status-icon { + border-color: var(--accent); + background: var(--accent); + animation: none; +} + +.sync-status-text { + color: var(--muted); +} + +/* Logout/disconnect button */ +.sync-logout { + margin-top: 25px; + padding: 8px 20px; + background: transparent; + border: 1px solid var(--muted); + color: var(--muted); + font-family: 'Special Elite', cursive; + font-size: 11px; + letter-spacing: 0.1em; + cursor: pointer; + transition: all 0.15s ease-out; +} + +.sync-logout:hover { + border-color: var(--accent); + color: var(--accent); +} +``` + +**Note:** No `border-radius` anywhere. Brutalist aesthetic maintained. + +### Modified file: `www/js/elements.js` + +Add new element references (in the `elements` object, ~line 69-71): + +```javascript +// Replace line 69: +syncBtn: null, // RENAME: this becomes offlineCacheBtn +// Add: +stateSyncBtn: null, +offlineCacheBtn: null, +syncModal: null, +syncModalClose: null, +syncForm: null, +syncUsername: null, +syncPassword: null, +syncError: null, +syncSubmit: null, +syncStatus: null, +syncLogout: null, +``` + +In `initElements()`, update (~line 137): + +```javascript +// Replace line 137: +elements.syncBtn = document.getElementById('sync-btn'); +// With: +elements.offlineCacheBtn = document.getElementById('offline-cache-btn'); +elements.stateSyncBtn = document.getElementById('state-sync-btn'); +elements.syncModal = document.getElementById('sync-modal'); +elements.syncModalClose = document.getElementById('sync-modal-close'); +elements.syncForm = document.getElementById('sync-form'); +elements.syncUsername = document.getElementById('sync-username'); +elements.syncPassword = document.getElementById('sync-password'); +elements.syncError = document.getElementById('sync-error'); +elements.syncSubmit = document.getElementById('sync-submit'); +elements.syncStatus = document.getElementById('sync-status'); +elements.syncLogout = document.getElementById('sync-logout'); +``` + +### Modified file: `www/js/ui.js` + +In `updateModeBasedUI()` (~lines 48-51), replace `syncBtn` references: + +```javascript +// BEFORE: +if (elements.syncBtn) { + elements.syncBtn.classList.toggle('hidden', !isSecret); +} + +// AFTER: +// Offline cache button: secret mode only +if (elements.offlineCacheBtn) { + elements.offlineCacheBtn.classList.toggle('hidden', !isSecret); +} +// State sync button: always visible (all users can sync) +if (elements.stateSyncBtn) { + elements.stateSyncBtn.classList.remove('hidden'); +} +``` + +### Modified file: `www/js/player.js` + +In `updateSyncUI()` (~line 722), update references from `syncBtn` to `offlineCacheBtn`: + +```javascript +export function updateSyncUI() { + if (!elements.offlineCacheBtn) return; + + const allCached = state.favoriteTracks.size > 0 && + [...state.favoriteTracks].every(id => state.cachedTracks.has(id)); + + elements.offlineCacheBtn.classList.toggle('syncing', state.cacheSyncing); + elements.offlineCacheBtn.classList.toggle('synced', allCached && !state.cacheSyncing); + // ... rest unchanged +} +``` + +### Modified file: `www/js/events.js` + +**1. Replace sync button binding** (~line 682): + +```javascript +// BEFORE: +if (elements.syncBtn) { + elements.syncBtn.addEventListener('click', syncFavoritesCache); +} + +// AFTER: +// Offline cache button (renamed from sync-btn) +if (elements.offlineCacheBtn) { + elements.offlineCacheBtn.addEventListener('click', syncFavoritesCache); +} + +// State sync button — opens sync modal +if (elements.stateSyncBtn) { + elements.stateSyncBtn.addEventListener('click', openSyncModal); +} +``` + +**2. Add sync modal handler setup** in `init()`: + +```javascript +import { pullState, pushState, fullSync, getSyncUsername, saveSyncUsername, clearSyncCredentials } from './sync.js'; + +// ... inside init(): +setupSyncModalHandlers(); +``` + +**3. Add `setupSyncModalHandlers` function and `openSyncModal`:** + +```javascript +function openSyncModal() { + if (!elements.syncModal) return; + const savedUsername = getSyncUsername(); + if (savedUsername && elements.syncUsername) { + elements.syncUsername.value = savedUsername; + } + elements.syncModal.classList.remove('hidden'); + // Focus first empty field + if (elements.syncUsername.value) { + elements.syncPassword.focus(); + } else { + elements.syncUsername.focus(); + } +} + +function closeSyncModal() { + if (!elements.syncModal) return; + elements.syncModal.classList.add('hidden'); + // Reset status + if (elements.syncError) elements.syncError.classList.add('hidden'); + if (elements.syncStatus) elements.syncStatus.classList.add('hidden'); +} + +function setupSyncModalHandlers() { + if (elements.syncModalClose) { + elements.syncModalClose.addEventListener('click', closeSyncModal); + } + // Backdrop closes modal + const backdrop = elements.syncModal?.querySelector('.modal-backdrop'); + if (backdrop) { + backdrop.addEventListener('click', closeSyncModal); + } + + if (elements.syncForm) { + elements.syncForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const username = elements.syncUsername.value.trim(); + const password = elements.syncPassword.value; + if (!username || !password) return; + + // Show loading + elements.syncSubmit.disabled = true; + elements.syncSubmit.textContent = 'SYNCING...'; + elements.syncError.classList.add('hidden'); + elements.syncStatus.classList.remove('hidden', 'success', 'error'); + elements.syncStatus.querySelector('.sync-status-text').textContent = 'Syncing...'; + + // State sync button spinner + elements.stateSyncBtn?.classList.add('syncing'); + + const result = await fullSync(username, password); + + elements.stateSyncBtn?.classList.remove('syncing'); + elements.syncSubmit.disabled = false; + elements.syncSubmit.textContent = 'SYNC'; + + if (result.status === 'ok') { + saveSyncUsername(username); + elements.syncStatus.classList.add('success'); + elements.syncStatus.querySelector('.sync-status-text').textContent = 'Synced!'; + elements.stateSyncBtn?.classList.add('connected'); + elements.syncLogout?.classList.remove('hidden'); + + // Flash success on button + elements.stateSyncBtn?.classList.add('sync-success'); + setTimeout(() => elements.stateSyncBtn?.classList.remove('sync-success'), 1500); + + // Refresh UI + renderTrackList(); + updateCatalogProgress(); + updateModeBasedUI(); + + // Auto-close after success + setTimeout(closeSyncModal, 1200); + } else { + elements.syncStatus.classList.add('error'); + elements.syncStatus.querySelector('.sync-status-text').textContent = result.error || 'Sync failed'; + elements.syncError.textContent = result.error || 'Sync failed'; + elements.syncError.classList.remove('hidden'); + + // Flash error on button + elements.stateSyncBtn?.classList.add('sync-error'); + setTimeout(() => elements.stateSyncBtn?.classList.remove('sync-error'), 1500); + } + }); + } + + if (elements.syncLogout) { + elements.syncLogout.addEventListener('click', () => { + clearSyncCredentials(); + elements.syncUsername.value = ''; + elements.syncPassword.value = ''; + elements.stateSyncBtn?.classList.remove('connected'); + elements.syncLogout.classList.add('hidden'); + elements.syncStatus.classList.add('hidden'); + }); + } +} +``` + +--- + +## T5: Wiring + Offline Bug Fix + +### Offline Cache Bug Fix + +**The bug:** `isTrackCached()` in `pwa.js` only checks the Service Worker Cache API (`audio-v1`), but `syncFavoritesCache()` writes to IndexedDB (`crate_cache`). When offline, `getNextTrack()` and `filterTracks()` call `isTrackCached()` and find nothing. + +**The fix:** Make `isTrackCached()` check BOTH systems. Since IndexedDB cached track IDs are already loaded into `state.cachedTracks` (a Set of track IDs), the simplest fix is: + +### Modified file: `www/js/pwa.js` + +Update `isTrackCached()` (~line 31): + +```javascript +// BEFORE: +export function isTrackCached(track) { + if (!track || !track.path) return false; + return cachedTracks.has(getMediaUrl(track.path)); +} + +// AFTER: +export function isTrackCached(track) { + if (!track || !track.path) return false; + // Check SW Cache API (auto-cached on play) + if (cachedTracks.has(getMediaUrl(track.path))) return true; + // Check IndexedDB cache (manually synced via offline cache button) + if (state.cachedTracks && state.cachedTracks.has(track.id)) return true; + return false; +} +``` + +This is the minimal fix. The import for `state` already exists in pwa.js. + +**Why this works:** `state.cachedTracks` is populated from IndexedDB at startup in `startPlayer()` (player.js line 623-628). The SW cache is populated in `populateCachedTracks()`. By checking both, offline filtering sees all cached tracks regardless of which cache system stored them. + +--- + +## Encryption Flow (detailed) + +``` +1. User enters: username="alice", password="hunter2" + +2. Key derivation: + PBKDF2( + password: "hunter2", + salt: TextEncoder.encode("alice"), // username IS the salt + iterations: 100,000, + hash: SHA-256 + ) → 256-bit AES-GCM CryptoKey + +3. Serialize state: + JSON.stringify({ + favoriteTracks: ["track_001", "track_042"], + heardTracks: ["track_001", "track_003", ...], + secretUnlocked: true, + syncedAt: "2026-03-31T..." + }) + +4. Encrypt: + iv = crypto.getRandomValues(12 bytes) + ciphertext = AES-GCM.encrypt(key, iv, plaintext_bytes) + → base64(ciphertext), base64(iv) + +5. Write hash: + write_hash = hex(SHA-256("hunter2" + "alice")) + → "a1b2c3d4..." (64 hex chars) + +6. PUT /sync/alice: + { + ciphertext: "base64...", + iv: "base64...", + salt: "alice", + write_hash: "a1b2c3d4..." + } + +7. Server stores at s3://tracks-bucket/sync/alice.json + Server validates write_hash matches existing (if any) +``` + +--- + +## Merge Logic (pseudocode) + +``` +function merge(local, remote): + // Favorites: UNION on pull, FULL REPLACE on push + for id in remote.favoriteTracks: + local.favoriteTracks.add(id) // union: add remote favorites locally + + // Heard tracks: UNION (never un-hear a track) + for id in remote.heardTracks: + local.heardTracks.add(id) // union: add remote heard locally + + // Secret unlocked: OR (once unlocked, stays unlocked) + if remote.secretUnlocked: + local.secretUnlocked = true + + // After merge, push replaces remote with merged local state + push(local) // full replace — merged state becomes source of truth +``` + +**Edge case: unfavoriting.** If Device A unfavorites track X, then syncs, Device B still has X as favorite. Next sync from B will re-add X. This is intentional (union merge = favorites only grow). To support deletions, you'd need a tombstone/vector clock system. For v1, union-only is the right tradeoff. + +--- + +## CSS Animation Design + +All animations use `transform` and `opacity` only (GPU-composited, no layout thrashing). + +### 1. Modal slide-in +```css +@keyframes sync-modal-enter { + from { opacity: 0; transform: translateY(40px) scale(0.95); } + to { opacity: 1; transform: translateY(0) scale(1); } +} +/* Easing: cubic-bezier(0.2, 0.8, 0.2, 1) — overshoots slightly, snappy */ +/* Duration: 350ms — fast but perceptible */ +``` + +### 2. Sync spinner (button icon rotation) +```css +@keyframes sync-spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} +/* Easing: cubic-bezier(0.4, 0, 0.2, 1) — accelerates into rotation */ +/* Duration: 800ms — fast enough to feel active, not frantic */ +``` + +### 3. Success confirmation (button flash) +```css +@keyframes sync-success-flash { + 0% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.7; transform: scale(1.15); } + 100% { opacity: 1; transform: scale(1); } +} +/* Duration: 400ms — quick pop, similar to existing fav-pop */ +/* Plus color transition to green (#4a4), reverts after 1.5s via JS */ +``` + +### 4. Button hover/active feedback +```css +/* Hover: gentle rotation hint */ +.state-sync-btn:hover svg { transform: rotate(30deg); } +/* Active: fast snap rotation */ +.state-sync-btn:active svg { transform: rotate(180deg); transition-duration: 0.15s; } +``` + +### 5. Connected indicator (green dot) +```css +/* Small green dot in bottom-right corner of button, no animation — always present when connected */ +.state-sync-btn.connected::after { + content: ''; + position: absolute; bottom: 4px; right: 4px; + width: 6px; height: 6px; background: #4a4; +} +``` + +--- + +## Sync Button Conflict — Analysis & Recommendation + +**Analysis of the three options:** + +1. **Separate buttons (recommended, matches your preference):** The offline cache button becomes `#offline-cache-btn` with the cloud-download icon (existing). The state sync button becomes `#state-sync-btn` with a two-arrow refresh icon (new). The existing `#sync-btn` ID and all references (`elements.syncBtn`, the event listener in events.js line 682, the `updateSyncUI()` calls in player.js) are renamed to `offlineCacheBtn` / `offline-cache-btn`. + +2. **Combined button:** Would save header space but conflates two different operations with different mental models (one is "make audio available offline," the other is "sync my library state to the cloud"). Confusing UX. + +3. **Replace:** Offline caching has a real use case (airplane mode), even if the current implementation is buggy. Removing it would lose functionality. + +**Recommendation: Option 1 (separate buttons).** The header has room — it currently has: back-btn, search-input, favs-filter-btn, sync-btn. Adding one more button is fine. On mobile the search input can flex to accommodate. + +**Migration checklist for existing `#sync-btn` references:** + +| File | Location | Current Reference | New Reference | +|------|----------|-------------------|---------------| +| `index.html` | line 152 | `id="sync-btn"` | `id="offline-cache-btn"` | +| `elements.js` | line 69 | `syncBtn: null` | `offlineCacheBtn: null` + `stateSyncBtn: null` | +| `elements.js` | line 137 | `getElementById('sync-btn')` | `getElementById('offline-cache-btn')` | +| `events.js` | line 682 | `elements.syncBtn` | `elements.offlineCacheBtn` | +| `player.js` | line 722-743 | `elements.syncBtn` (in `updateSyncUI`) | `elements.offlineCacheBtn` | +| `ui.js` | line 48-51 | `elements.syncBtn` | `elements.offlineCacheBtn` | +| `main.css` | line 683 | `.search-sync-btn` | keep (shared class), add `.state-sync-btn` | + +--- + +## Integration Sequence (build order) + +``` +Phase 1 — Foundation (all parallel) +├── T1: terraform apply (Lambda + CloudFront behavior) +├── T2: Write Lambda handler, zip, deploy +├── T3: Write crypto.js + sync.js modules +└── T4: HTML/CSS changes, elements.js updates + +Phase 2 — Wiring (after T3 + T4) +├── T5a: events.js — import sync module, wire modal handlers +├── T5b: events.js — rename offline cache button binding +├── T5c: player.js — rename syncBtn → offlineCacheBtn in updateSyncUI +├── T5d: ui.js — update updateModeBasedUI for new buttons +└── T5e: pwa.js — fix isTrackCached() to check both caches + +Phase 3 — Test + polish +├── Manual test: login, push, pull on different browser +├── Verify offline playback works after cache bug fix +├── Verify modal animations are smooth +└── Verify sync button shows connected state on reload +``` + +--- + +## New Files Summary + +| File | Purpose | +|------|---------| +| `www/js/crypto.js` | PBKDF2 key derivation, AES-GCM encrypt/decrypt, SHA-256 write hash | +| `www/js/sync.js` | Push/pull/merge orchestration, credential storage | +| `terraform/lambda-sync.tf` | Lambda function, IAM role, Function URL, CloudFront behavior | +| `terraform/lambda/index.mjs` | Lambda handler — GET/PUT encrypted blobs to S3 | + +## Modified Files Summary + +| File | Changes | +|------|---------| +| `www/index.html` | Rename sync-btn to offline-cache-btn, add state-sync-btn, add sync-modal | +| `www/main.css` | Add ~120 lines: sync button states, modal styles, keyframes | +| `www/js/elements.js` | Replace `syncBtn` with `offlineCacheBtn` + `stateSyncBtn`, add modal elements | +| `www/js/events.js` | Import sync module, add sync modal handlers, rename cache button binding | +| `www/js/player.js` | Rename `syncBtn` to `offlineCacheBtn` in `updateSyncUI()` | +| `www/js/ui.js` | Update `updateModeBasedUI()` for new button names | +| `www/js/pwa.js` | Fix `isTrackCached()` to check IndexedDB cache too | +| `terraform/cloudfront.tf` | Add Lambda origin + `/sync/*` cache behavior | +| `terraform/outputs.tf` | Add `sync_lambda_url` output | diff --git a/.claude/plans/magical-petting-snowglobe.md b/.claude/plans/magical-petting-snowglobe.md new file mode 100644 index 0000000..b5d1483 --- /dev/null +++ b/.claude/plans/magical-petting-snowglobe.md @@ -0,0 +1,227 @@ +# Profile Screen + Stats Dashboard + Debug-in-UI Principle + +## Context + +The sync button (↻) is an implementation detail masquerading as a feature. Users don't think in terms of "sync" — they think in terms of "me" and "my stuff." The sync button should become a **profile screen** — a personal dashboard showing listening stats, sync health, and (during development) debug information. + +Additionally, this work introduces a **debug-in-UI** design principle: during UI development, render debug state visually in the page itself (not just console.log), organized for easy removal when the feature is stable. + +--- + +## Profile Screen — What It Shows + +### Identity +- **Username** (large, top, `Bebas Neue`) — from `getSyncCredentials().username` +- No profile pic, no avatar — just the name + +### Stats (computed from state + new tracking fields) +| Stat | Source | New? | +|------|--------|------| +| Tracks heard | `state.heardTracks.size` | No | +| Catalog % | `heardTracks.size / tracks.length` | No | +| Favorites count | `state.favoriteTracks.size` | No | +| Total listen time | **New**: `state.totalListenSeconds` | Yes | +| Last played | **New**: `state.lastPlayedAt` | Yes | +| Total unique tracks (non-resetting) | **New**: `state.totalUniqueHeard` | Yes | + +### Sync Status +- **Status indicator**: Connected / Disconnected / Error +- **Last synced**: timestamp from `syncedAt` (already in serialized state) +- **Sync problems**: clear text explaining issues ("Wrong password", "Server unreachable", etc.) +- **Remediation**: actionable text ("Tap to retry", "Re-enter credentials", "Check network") +- **Manual sync button**: force push/pull from this screen +- **Disconnect button**: clear credentials, return to "new user" state + +### Debug Panel (development only, organized for removal) +- All debug items wrapped in a single `
` +- Easy to hide with one CSS rule or remove the entire div +- Shows: raw sync credentials status, localStorage keys/sizes, SW cache state, last push/pull result, network state, service worker registration status + +--- + +## New Data Tracking + +### State additions (`www/js/state.js`) +```js +totalListenSeconds: 0, // cumulative, never resets +totalUniqueHeard: 0, // cumulative, never resets (heardTracks resets on full cycle) +lastPlayedAt: null, // ISO timestamp +lastSyncResult: null, // { status, error?, timestamp } +``` + +### Storage additions (`www/js/storage.js`) +- `saveListenStats()` / `loadListenStats()` — persists `totalListenSeconds`, `totalUniqueHeard`, `lastPlayedAt` + +### Sync additions (`www/js/sync.js`) +- `serializeState()` — include `totalListenSeconds`, `totalUniqueHeard`, `lastPlayedAt` +- `mergeState()` — take `max()` for counters, most-recent for timestamps + +### Player instrumentation (`www/js/player.js`) +- `playTrack()`: set `state.lastPlayedAt = new Date().toISOString()`, record `state._playStartTime = Date.now()` +- `handleTrackEnded()`: add elapsed seconds to `state.totalListenSeconds`, save +- `handlePlayPause()`: on pause, add elapsed since `_playStartTime`; on play, reset `_playStartTime` +- `markTrackHeard()` (in `tracks.js`): increment `state.totalUniqueHeard` only when ID is genuinely new + +--- + +## Tasks (5 units, T1-T3 parallelizable) + +### T1: Stats Tracking Infrastructure +**Modified files**: `state.js`, `storage.js`, `sync.js`, `config.js` + +- Add 4 new state fields +- Add `saveListenStats()` / `loadListenStats()` to storage +- Add new config keys for localStorage +- Extend `serializeState()` and `mergeState()` in sync.js +- Merge strategy: `max()` for counters, most-recent for timestamps + +### T2: Player Instrumentation +**Modified files**: `player.js`, `tracks.js` + +- Track play start time in `playTrack()` +- Accumulate listen seconds in `handleTrackEnded()` and pause handler +- Set `lastPlayedAt` on each play +- Increment `totalUniqueHeard` in `markTrackHeard()` (only for new IDs) +- Call `saveListenStats()` after mutations + +### T3: Profile Screen HTML + CSS +**Modified files**: `index.html`, `main.css` + +HTML structure: +```html +
+
+ + +

---

+ +
+
+ LISTENED + 0h 0m +
+
+ TRACKS HEARD + 0 / 0 +
+
+ FAVORITES + 0 +
+
+ LAST PLAYED + --- +
+
+ +
+
+ + Not connected +
+ +
+ + +
+
+ + <\!-- DEBUG: Remove before release --> +
+

DEBUG

+

+    
+
+
+``` + +CSS: +- Brutalist: no border-radius, monospace for values, `Bebas Neue` for labels +- `.profile-container` — `max-width: 400px`, centered, `padding-top: 80px` +- `.stat-row` — flex between label and value, `border-bottom: 1px solid var(--muted)` +- `.sync-status-dot` — 8px circle, green/red/yellow based on class +- `.profile-debug` — `border: 1px dashed var(--muted)`, `font-size: 11px`, monospace, slightly dimmed +- All `.profile-debug` items use a `[data-debug]` attribute for easy querySelectorAll removal + +### T4: Profile Screen Wiring +**Modified files**: `elements.js`, `events.js`, `state.js`, `ui.js`, `sw.js` + +- Add `SCREENS.PROFILE` and `SCREEN_ID_MAP['profile-screen']` +- Add element refs: `profileScreen`, `profileBackBtn`, `profileUsername`, stat elements, sync elements, debug output +- Replace sync button click handler: now opens profile screen instead of prompting +- `showScreen('profile-screen')` → populate stats, sync status, debug info +- Profile back button → `showScreen('player-screen')` +- Profile sync button → force `fullSync()` with visual feedback +- Profile disconnect → `clearSyncCredentials()`, update UI, show "enter creds" state +- Title logo: add `'profile-screen'` to the `at-top` condition in `ui.js:98` +- Bump SW shell cache to `shell-v3` + +### T5: Enter Screen Personalization +**Modified files**: `index.html`, `elements.js`, `events.js`, `main.css` + +- Add `#enter-greeting` and `#enter-username-display` elements (hidden by default) +- Add `#enter-creds` form (username + password inputs, hidden by default) +- In `init()`: if creds exist → show "WELCOME BACK" + username, button = "ENTER" +- In `init()`: if no creds → show credential inputs, button = "CONNECT" +- On CONNECT click: save creds, kick off non-blocking `fullSync()`, enter player +- On ENTER click (returning user): proceed as normal (auto-pull already fired) + +### T6: GitHub Issues +1. **Account recovery** — zero-knowledge means no server-side recovery. Options to explore: recovery codes, local export/import, "just create new account" +2. **PDS ethos: Debug-in-UI** — design principle for rendering debug state visually during development, organized for removal + +### T7: Debug-in-UI Skill +**New file**: `.claude/skills/debug-in-ui.md` + +Principle: When building UI features, render relevant debug state in the page itself — not just `console.log`. Rules: +- Wrap all debug elements in a single container with a predictable ID pattern (`#*-debug`) +- Use `data-debug` attributes on individual items +- Use `class="debug-*"` for styling (dashed borders, monospace, dimmed) +- CSS: `.debug-*` styles grouped in one block with `/* DEBUG STYLES — REMOVE */` comment +- JS: debug population logic in clearly marked functions (`populateDebugInfo()`) +- Removal checklist: delete HTML container, delete CSS block, delete JS function, grep for `debug` to verify clean + +--- + +## Files Summary + +### New Files +| File | Purpose | +|------|---------| +| `.claude/skills/debug-in-ui.md` | Design principle skill for visual debug during UI dev | + +### Modified Files +| File | Changes | +|------|---------| +| `www/index.html` | Profile screen HTML, enter screen greeting + creds form | +| `www/main.css` | ~100 lines: profile layout, stat rows, sync status, debug panel, enter creds | +| `www/js/state.js` | Add `SCREENS.PROFILE`, 4 new tracking fields | +| `www/js/storage.js` | `saveListenStats()` / `loadListenStats()` | +| `www/js/sync.js` | Extend serialize/merge with new stats fields | +| `www/js/config.js` | New localStorage key for listen stats | +| `www/js/player.js` | Listen time tracking, lastPlayedAt, play start time | +| `www/js/tracks.js` | `totalUniqueHeard` increment in markTrackHeard | +| `www/js/elements.js` | ~15 new element refs (profile + enter screen) | +| `www/js/events.js` | Profile screen navigation, enter flow personalization, sync → profile redirect | +| `www/js/ui.js` | Add profile to SCREEN_ID_MAP, title logo handling | +| `www/sw.js` | Bump to shell-v3 (no new JS module needed — profile logic lives in events.js) | + +--- + +## Verification + +1. **New user**: Clear localStorage → see username/password inputs + "CONNECT" +2. **Connect**: Fill creds, click CONNECT → creds saved, player starts, background sync +3. **Returning user**: Reload → "WELCOME BACK" + username, "ENTER" button +4. **Profile screen**: Tap sync/profile icon → see name, stats, sync status +5. **Stats update**: Play tracks → listen time increments, heard count grows, last played updates +6. **Stats persist**: Reload → stats survive (localStorage) +7. **Stats sync**: Sync from device A → pull on device B → stats merge (max counters) +8. **Sync status**: Profile shows green dot when connected, error details on failure +9. **Force sync**: Tap "SYNC NOW" on profile → spinner → success/error feedback +10. **Disconnect**: Tap disconnect → creds cleared, next reload shows new user state +11. **Debug panel**: Visible during dev, shows localStorage state, SW status, sync result +12. **Debug removal**: Delete `#profile-debug` div + CSS block + JS function → clean removal +13. **Konami**: Still works on enter screen (inputs don't capture arrow keys) diff --git a/.claude/skills/debug-in-ui.md b/.claude/skills/debug-in-ui.md new file mode 100644 index 0000000..e268ee5 --- /dev/null +++ b/.claude/skills/debug-in-ui.md @@ -0,0 +1,65 @@ +# Debug-in-UI + +When building UI features, render relevant debug state **visually in the page** — not just in console.log. This gives immediate feedback during development and makes state bugs visible at a glance. + +## Principle + +Debug information should be: +- **Visible** — rendered in the actual UI, not hidden in devtools +- **Organized** — grouped in a single container per feature/screen +- **Removable** — structured for clean deletion when the feature stabilizes + +## Implementation Pattern + +### HTML +Wrap all debug elements in a single container with a predictable ID: + + +
+

DEBUG

+

+    
+ +Use data-debug attributes on individual items when scattered across the page: + + connected + +### CSS +Group all debug styles in one clearly marked block: + + /* DEBUG STYLES — REMOVE BEFORE RELEASE */ + .feature-debug { border: 1px dashed var(--muted); padding: 16px; opacity: 0.7; } + .debug-heading { /* ... */ } + .debug-output { /* ... */ } + /* END DEBUG STYLES */ + +### JavaScript +Isolate debug population in clearly named functions: + + /** DEBUG: Remove before release */ + function populateDebugInfo() { + const output = document.getElementById('debug-output'); + if (!output) return; + output.textContent = JSON.stringify({ key: 'value' }, null, 2); + } + +## Removal Checklist + +When the feature is stable: +1. Delete the HTML debug container +2. Delete the CSS block between DEBUG STYLES markers +3. Delete the JS populateDebugInfo() function and call sites +4. Remove any data-debug attributes +5. Grep for "debug" to verify clean removal + +## When to Use + +- Always when building new UI screens or features +- Always when implementing state sync, caching, or offline features +- Always when the feature has complex state that could silently break + +## When NOT to Use + +- Simple styling changes with no state +- Backend-only changes +- Changes where automated tests provide sufficient coverage diff --git a/CHANGELOG.md b/CHANGELOG.md index cbe5bc4..0101a5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,51 @@ # Changelog +## [1.3.0] - 2026-04-03 + +### Added +- Smoke background: layered animated radial gradients with CSS custom properties, `prefers-reduced-motion` support +- Dante's Circles: 9 unlockable smoke color themes tied to unique tracks heard (0–100 milestones) +- Circle progress on profile screen: 9 dot marks, tappable to switch between unlocked themes +- Generative artwork: canvas-based animated placeholder for tracks without cover art, unique per track +- Cash rain depth: 150 bills across 5 parallax size tiers with weighted distribution +- Inline credential form on profile screen for sync opt-in (replaces prompt-based flow) +- Theme config: `site.md` frontmatter supports `theme.circles` for per-instance color overrides +- Circle state syncs across devices (highest circle wins on merge) + +### Changed +- Enter screen stripped to just ENTER button — no credential inputs, no greeting +- Profile screen: unconnected users see stats + credential form; connected users see stats + sync status + circle progress +- Artwork long-press download and click-to-search gestures bind to container (works with both real artwork and generated art) +- Service worker bumped to shell-v4 with circles.js, genart.js, and bill images + +## [1.2.0] - 2026-04-02 + +### Added +- Profile screen with stats dashboard (listen time, tracks heard, favorites, last played) +- Enter screen personalization: returning users see "WELCOME BACK", new users see credential inputs +- Listen stats tracking: cumulative time, unique tracks, last played timestamp +- Stats sync across devices using max() merge strategy +- Debug-in-UI skill for visual debug panels during development +- Sync status with remediation on profile screen (connected/error/disconnect) + +### Changed +- Sync button replaced with profile icon (person silhouette) in player bar +- Profile screen is the new home for sync controls and status + +## [1.1.0] - 2026-03-31 + +### Added +- Cross-device state sync with zero-knowledge encryption (PBKDF2 + AES-256-GCM) +- Lambda sync endpoint with S3 storage (`/sync/*` via CloudFront) +- Client-side crypto module (Web Crypto API) for key derivation, encrypt/decrypt +- Sync button in player secondary controls (always visible, prompt-based auth) +- Auto-pull on app load when credentials exist; debounced push on state changes +- Play history persistence and sync across devices +- Terraform infrastructure for sync Lambda, IAM, and CloudFront behavior + +### Fixed +- Offline listening broken: unified SW Cache API and IndexedDB cache checks in `isTrackCached()` + ## [0.1.0] - 2026-02-14T01:12:36+00:00 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 6f7d054..0643144 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,6 +29,7 @@ AI-assisted development methodology. Skills for consistency. Agents for scale. | `/commit` | Before any git commit | | `/review` | Before submitting or reviewing PRs | | `/debug` | When troubleshooting issues | +| `/debug-in-ui` | Visual debug rendering during UI development | | `/test` | Writing or running tests | | `/design` | Architecture decisions, new features | | `/worktree` | Branch isolation, parallel work | diff --git a/VERSION b/VERSION index 6e8bf73..f0bb29e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0 +1.3.0 diff --git a/package.json b/package.json index 876dadf..2a9dc57 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "crate", - "version": "1.0.0", + "version": "1.2.0", "description": "Self-hosted music streaming PWA", "scripts": { "build": "node tools/obfuscate.js && node tools/build-config.js", diff --git a/site.md b/site.md index ec750d0..79634d7 100644 --- a/site.md +++ b/site.md @@ -1,6 +1,18 @@ --- name: Crate url: https://crate.rmzi.world +admin: rmzi +theme: + circles: + limbo: "rgba(255,255,255,0.25)" + lust: "rgba(200,50,80,0.25)" + gluttony: "rgba(212,175,55,0.25)" + greed: "rgba(50,180,80,0.25)" + wrath: "rgba(220,30,30,0.25)" + heresy: "rgba(230,120,20,0.25)" + violence: "rgba(140,20,30,0.25)" + fraud: "rgba(120,40,180,0.25)" + treachery: "rgba(100,180,255,0.25)" --- ### What diff --git a/terraform/.terraform.lock.hcl b/terraform/.terraform.lock.hcl index f0d41ca..e055969 100644 --- a/terraform/.terraform.lock.hcl +++ b/terraform/.terraform.lock.hcl @@ -5,6 +5,7 @@ provider "registry.terraform.io/hashicorp/aws" { version = "5.100.0" constraints = "~> 5.0" hashes = [ + "h1:Ijt7pOlB7Tr7maGQIqtsLFbl7pSMIj06TVdkoSBcYOw=", "h1:edXOJWE4ORX8Fm+dpVpICzMZJat4AX0VRCAy/xkcOc0=", "zh:054b8dd49f0549c9a7cc27d159e45327b7b65cf404da5e5a20da154b90b8a644", "zh:0b97bf8d5e03d15d83cc40b0530a1f84b459354939ba6f135a0086c20ebbe6b2", @@ -29,6 +30,7 @@ provider "registry.terraform.io/hashicorp/tls" { constraints = "~> 4.0" hashes = [ "h1:F5d6bQY8UlBo0D71Sv7CsV+3aZOFz0yeNF+vufog7h4=", + "h1:akFNuHwvrtnYMBofieoeXhPJDhYZzJVu/Q/BgZK2fgg=", "zh:0d1e7d07ac973b97fa228f46596c800de830820506ee145626f079dd6bbf8d8a", "zh:5c7e3d4348cb4861ab812973ef493814a4b224bdd3e9d534a7c8a7c992382b86", "zh:7c6d4a86cd7a4e9c1025c6b3a3a6a45dea202af85d870cddbab455fb1bd568ad", diff --git a/terraform/cloudfront.tf b/terraform/cloudfront.tf index b9c404a..b39b2e0 100644 --- a/terraform/cloudfront.tf +++ b/terraform/cloudfront.tf @@ -74,6 +74,19 @@ resource "aws_cloudfront_distribution" "main" { origin_access_control_id = aws_cloudfront_origin_access_control.tracks.id } + # Origin 3: Sync Lambda Function URL + origin { + domain_name = replace(replace(aws_lambda_function_url.sync.function_url, "https://", ""), "/", "") + origin_id = "sync-lambda" + + custom_origin_config { + http_port = 80 + https_port = 443 + origin_protocol_policy = "https-only" + origin_ssl_protocols = ["TLSv1.2"] + } + } + # Default behavior: Site bucket (public) default_cache_behavior { allowed_methods = ["GET", "HEAD", "OPTIONS"] @@ -95,6 +108,28 @@ resource "aws_cloudfront_distribution" "main" { max_ttl = 86400 } + # Sync API behavior: Lambda Function URL (no caching) + ordered_cache_behavior { + path_pattern = "/sync/*" + allowed_methods = ["GET", "HEAD", "OPTIONS", "PUT", "POST", "PATCH", "DELETE"] + cached_methods = ["GET", "HEAD"] + target_origin_id = "sync-lambda" + viewer_protocol_policy = "redirect-to-https" + compress = true + + min_ttl = 0 + default_ttl = 0 + max_ttl = 0 + + forwarded_values { + query_string = false + headers = ["Origin", "Access-Control-Request-Method", "Access-Control-Request-Headers"] + cookies { + forward = "none" + } + } + } + # Audio behavior: Tracks bucket (requires signed cookies) ordered_cache_behavior { path_pattern = "/audio/*" diff --git a/terraform/lambda-sync.tf b/terraform/lambda-sync.tf new file mode 100644 index 0000000..4b2b2b8 --- /dev/null +++ b/terraform/lambda-sync.tf @@ -0,0 +1,100 @@ +# ============================================================================ +# State Sync Lambda — encrypted state read/write via S3 +# ============================================================================ + +resource "aws_lambda_function" "sync" { + function_name = "${var.subdomain}-state-sync" + runtime = "nodejs20.x" + handler = "index.handler" + timeout = 10 + memory_size = 128 + + filename = "${path.module}/lambda/sync.zip" + source_code_hash = filebase64sha256("${path.module}/lambda/sync.zip") + + role = aws_iam_role.sync_lambda.arn + + environment { + variables = { + BUCKET_NAME = aws_s3_bucket.tracks.id + SYNC_PREFIX = "sync/" + } + } +} + +# Lambda Function URL (no API Gateway needed) +resource "aws_lambda_function_url" "sync" { + function_name = aws_lambda_function.sync.function_name + authorization_type = "NONE" + + cors { + allow_origins = ["*"] + allow_methods = ["GET", "PUT"] + allow_headers = ["Content-Type"] + max_age = 3600 + } +} + +# Public access permission for Function URL +resource "aws_lambda_permission" "sync_public" { + statement_id = "FunctionURLAllowPublicAccess" + action = "lambda:InvokeFunctionUrl" + function_name = aws_lambda_function.sync.function_name + principal = "*" + + function_url_auth_type = "NONE" +} + +resource "aws_lambda_permission" "sync_invoke" { + statement_id = "InvokeAll" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.sync.function_name + principal = "*" +} + +# IAM role for Lambda +resource "aws_iam_role" "sync_lambda" { + name = "${var.subdomain}-sync-lambda-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { Service = "lambda.amazonaws.com" } + }] + }) +} + +# Lambda basic execution (CloudWatch logs) +resource "aws_iam_role_policy_attachment" "sync_lambda_logs" { + role = aws_iam_role.sync_lambda.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" +} + +# S3 access for sync/ prefix only +resource "aws_iam_role_policy" "sync_lambda_s3" { + name = "${var.subdomain}-sync-lambda-s3" + role = aws_iam_role.sync_lambda.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = ["s3:GetObject", "s3:PutObject"] + Resource = "${aws_s3_bucket.tracks.arn}/sync/*" + }, + { + Effect = "Allow" + Action = ["s3:ListBucket"] + Resource = aws_s3_bucket.tracks.arn + Condition = { + StringLike = { + "s3:prefix" = ["sync/*"] + } + } + } + ] + }) +} diff --git a/terraform/lambda/index.mjs b/terraform/lambda/index.mjs new file mode 100644 index 0000000..d77988f --- /dev/null +++ b/terraform/lambda/index.mjs @@ -0,0 +1,93 @@ +import { S3Client, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3'; + +const s3 = new S3Client({}); +const BUCKET = process.env.BUCKET_NAME; +const PREFIX = process.env.SYNC_PREFIX || 'sync/'; + +export async function handler(event) { + const method = event.requestContext?.http?.method || event.httpMethod; + const path = event.rawPath || event.path || ''; + const username = path.replace(/^\/sync\//, '').replace(/\.json$/, ''); + + if (!username || username.includes('/') || username.includes('..') || username.length > 64) { + return respond(200, { error: 'Invalid username' }); + } + + const key = `${PREFIX}${username}.json`; + + if (method === 'GET') { + return handleGet(key); + } else if (method === 'PUT') { + return handlePut(key, event); + } else { + return respond(200, { error: 'Method not allowed' }); + } +} + +async function handleGet(key) { + try { + const result = await s3.send(new GetObjectCommand({ Bucket: BUCKET, Key: key })); + const body = await result.Body.transformToString(); + return respond(200, JSON.parse(body)); + } catch (e) { + if (e.name === 'NoSuchKey') { + return respond(200, { found: false }); + } + console.error('GET error:', e); + return respond(200, { error: 'Internal error' }); + } +} + +async function handlePut(key, event) { + let body; + try { + body = JSON.parse(event.body); + } catch { + return respond(200, { error: 'Invalid JSON' }); + } + + const { ciphertext, iv, salt, write_hash } = body; + if (!ciphertext || !iv || !salt || !write_hash) { + return respond(200, { error: 'Missing required fields: ciphertext, iv, salt, write_hash' }); + } + + // Check existing write_hash (if record exists, must match) + try { + const existing = await s3.send(new GetObjectCommand({ Bucket: BUCKET, Key: key })); + const existingData = JSON.parse(await existing.Body.transformToString()); + if (existingData.write_hash && existingData.write_hash !== write_hash) { + return respond(200, { error: 'Invalid credentials' }); + } + } catch (e) { + if (e.name !== 'NoSuchKey') { + console.error('Auth check error:', e); + return respond(200, { error: 'Internal error' }); + } + // NoSuchKey = new user, allow creation + } + + const record = { + ciphertext, + iv, + salt, + write_hash, + updated_at: new Date().toISOString() + }; + + await s3.send(new PutObjectCommand({ + Bucket: BUCKET, + Key: key, + Body: JSON.stringify(record), + ContentType: 'application/json' + })); + + return respond(200, { ok: true, updated_at: record.updated_at }); +} + +function respond(status, body) { + return { + statusCode: status, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }; +} diff --git a/terraform/lambda/sync.zip b/terraform/lambda/sync.zip new file mode 100644 index 0000000..84c9d34 Binary files /dev/null and b/terraform/lambda/sync.zip differ diff --git a/terraform/outputs.tf b/terraform/outputs.tf index 0404bea..0917435 100644 --- a/terraform/outputs.tf +++ b/terraform/outputs.tf @@ -42,3 +42,8 @@ output "metadata_agent_role_arn" { description = "ARN of the IAM role for metadata agent" value = aws_iam_role.metadata_agent.arn } + +output "sync_lambda_url" { + description = "Lambda Function URL for state sync" + value = aws_lambda_function_url.sync.function_url +} diff --git a/tools/build-config.js b/tools/build-config.js index 99f4c46..a3faa63 100644 --- a/tools/build-config.js +++ b/tools/build-config.js @@ -10,6 +10,7 @@ */ const fs = require('fs'); +const crypto = require('crypto'); const path = require('path'); const matter = require('gray-matter'); const { marked } = require('marked'); @@ -33,11 +34,13 @@ const siteObj = { url: frontmatter.url || '', password: frontmatter.password || null, gaTrackingId: frontmatter.ga_tracking_id || null, + admin: frontmatter.admin ? crypto.createHash('sha256').update(frontmatter.admin).digest('hex') : null, theme: { accent: frontmatter.theme?.accent || '#ff0000', font: frontmatter.theme?.font || "'Special Elite', cursive", titleFont: frontmatter.theme?.title_font || "'Anton', Impact, sans-serif", searchFont: frontmatter.theme?.search_font || "'Bebas Neue', sans-serif", + circles: frontmatter.theme?.circles || null, } }; diff --git a/www/img/benj_back.jpeg b/www/img/benj_back.jpeg new file mode 100644 index 0000000..f6ee376 Binary files /dev/null and b/www/img/benj_back.jpeg differ diff --git a/www/img/benj_front.jpeg b/www/img/benj_front.jpeg new file mode 100644 index 0000000..187421a Binary files /dev/null and b/www/img/benj_front.jpeg differ diff --git a/www/index.html b/www/index.html index 0591ace..eeceb3c 100644 --- a/www/index.html +++ b/www/index.html @@ -58,6 +58,7 @@

Crate

+
@@ -126,6 +127,9 @@

Crate

+
@@ -149,11 +153,11 @@

Crate

- + + +
+
+ + +

---

+ +
+
+ LISTENED + 0h 0m +
+
+ TRACKS HEARD + 0 / 0 +
+
+ FAVORITES + 0 +
+
+ LAST PLAYED + --- +
+
+ + + + + +
+ + + + + +
+ + +
+

DEBUG

+

+        
+
+
+ + + + + + <\!-- Sync credentials modal --> + + + @@ -210,4 +326,5 @@

ERROR

- + + diff --git a/www/js/circles.js b/www/js/circles.js new file mode 100644 index 0000000..9ccae1e --- /dev/null +++ b/www/js/circles.js @@ -0,0 +1,113 @@ +/** + * Dante's Circles — smoke theme progression system + * @module circles + */ + +import { SITE } from './site.config.js'; + +/** + * Circle definitions with thresholds and default colors. + * Colors are arrays of 3 rgba strings for --smoke-1, --smoke-2, --smoke-3. + * Site config can override via SITE.theme.circles.{id} + */ +const DEFAULT_COLORS = { + limbo: ['rgba(255,255,255,0.25)', 'rgba(255,255,255,0.22)', 'rgba(255,255,255,0.18)'], + lust: ['rgba(200,50,80,0.25)', 'rgba(180,40,70,0.22)', 'rgba(160,30,60,0.18)'], + gluttony: ['rgba(212,175,55,0.25)', 'rgba(190,155,45,0.22)', 'rgba(170,140,35,0.18)'], + greed: ['rgba(50,180,80,0.25)', 'rgba(40,160,70,0.22)', 'rgba(30,140,60,0.18)'], + wrath: ['rgba(220,30,30,0.25)', 'rgba(200,20,20,0.22)', 'rgba(180,15,15,0.18)'], + heresy: ['rgba(230,120,20,0.25)', 'rgba(210,100,15,0.22)', 'rgba(190,85,10,0.18)'], + violence: ['rgba(140,20,30,0.25)', 'rgba(120,15,25,0.22)', 'rgba(100,10,20,0.18)'], + fraud: ['rgba(120,40,180,0.25)', 'rgba(100,30,160,0.22)', 'rgba(85,20,140,0.18)'], + treachery: ['rgba(100,180,255,0.25)', 'rgba(80,160,235,0.22)', 'rgba(60,140,215,0.18)'], +}; + +export const CIRCLES = [ + { id: 'limbo', threshold: 0 }, + { id: 'lust', threshold: 5 }, + { id: 'gluttony', threshold: 12 }, + { id: 'greed', threshold: 20 }, + { id: 'wrath', threshold: 35 }, + { id: 'heresy', threshold: 50 }, + { id: 'violence', threshold: 65 }, + { id: 'fraud', threshold: 80 }, + { id: 'treachery', threshold: 100 }, +]; + +/** + * Get colors for a circle, checking site config overrides first + * @param {string} circleId + * @returns {string[]} Array of 3 rgba color strings + */ +function getCircleColors(circleId) { + // Check site config override + const siteColors = SITE.theme?.circles?.[circleId]; + if (siteColors) { + // Site config can provide a single color string or array of 3 + if (Array.isArray(siteColors)) return siteColors; + // Single color — derive variants with slightly lower opacity + return [siteColors, siteColors.replace(/[\d.]+\)$/, m => (parseFloat(m) * 0.88).toFixed(2) + ')'), siteColors.replace(/[\d.]+\)$/, m => (parseFloat(m) * 0.72).toFixed(2) + ')')]; + } + return DEFAULT_COLORS[circleId] || DEFAULT_COLORS.limbo; +} + +/** + * Get the current circle based on unique tracks heard + * @param {number} uniqueHeard + * @returns {Object} Circle object {id, threshold} + */ +export function getCurrentCircle(uniqueHeard) { + let current = CIRCLES[0]; + for (const circle of CIRCLES) { + if (uniqueHeard >= circle.threshold) { + current = circle; + } + } + return current; +} + +/** + * Get all unlocked circles + * @param {number} uniqueHeard + * @returns {Object[]} Array of unlocked circle objects + */ +export function getUnlockedCircles(uniqueHeard) { + return CIRCLES.filter(c => uniqueHeard >= c.threshold); +} + +/** + * Apply a circle's smoke theme by setting CSS custom properties + * @param {string} circleId - Circle ID to apply + */ +export function applyCircleTheme(circleId) { + const colors = getCircleColors(circleId || 'limbo'); + const root = document.documentElement; + root.style.setProperty('--smoke-1', colors[0]); + root.style.setProperty('--smoke-2', colors[1]); + root.style.setProperty('--smoke-3', colors[2]); +} + +/** + * Check if a new circle was unlocked between prev and new heard counts + * @param {number} prevHeard - Previous totalUniqueHeard + * @param {number} newHeard - New totalUniqueHeard + * @returns {Object|null} Newly unlocked circle, or null + */ +export function checkCircleAdvancement(prevHeard, newHeard) { + const prevCircle = getCurrentCircle(prevHeard); + const newCircle = getCurrentCircle(newHeard); + if (newCircle.id !== prevCircle.id) { + return newCircle; + } + return null; +} + +/** + * Get circle index (0-8) for a given circle ID + * @param {string} circleId + * @returns {number} + */ +export function getCircleIndex(circleId) { + const idx = CIRCLES.findIndex(c => c.id === circleId); + return idx >= 0 ? idx : 0; +} diff --git a/www/js/config.js b/www/js/config.js index d229aca..65dd763 100644 --- a/www/js/config.js +++ b/www/js/config.js @@ -15,6 +15,7 @@ export const CONFIG = { STORAGE_KEY: `${prefix}_heard_tracks`, FAVORITES_KEY: `${prefix}_favorite_tracks`, SECRET_KEY: `${prefix}_secret_unlocked`, + STATS_KEY: `${prefix}_listen_stats`, COOKIE_NAMES: ['CloudFront-Policy', 'CloudFront-Signature', 'CloudFront-Key-Pair-Id'], PASSWORD: SITE.password }; diff --git a/www/js/crypto.js b/www/js/crypto.js new file mode 100644 index 0000000..87b3341 --- /dev/null +++ b/www/js/crypto.js @@ -0,0 +1,78 @@ +/** + * Client-side encryption for state sync + * Uses PBKDF2 for key derivation and AES-GCM for encryption. + * Zero-knowledge: the server never sees plaintext or password. + * @module crypto + */ + +const PBKDF2_ITERATIONS = 100000; +const KEY_LENGTH = 256; + +/** + * Derive an AES-GCM key from password + username + * @param {string} password + * @param {string} username - used as PBKDF2 salt + * @returns {Promise} + */ +export async function deriveKey(password, username) { + const enc = new TextEncoder(); + const keyMaterial = await crypto.subtle.importKey( + 'raw', enc.encode(password), 'PBKDF2', false, ['deriveKey'] + ); + return crypto.subtle.deriveKey( + { name: 'PBKDF2', salt: enc.encode(username), iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' }, + keyMaterial, + { name: 'AES-GCM', length: KEY_LENGTH }, + false, + ['encrypt', 'decrypt'] + ); +} + +/** + * Encrypt plaintext with AES-GCM + * @param {CryptoKey} key + * @param {string} plaintext + * @returns {Promise<{ciphertext: string, iv: string}>} base64-encoded + */ +export async function encrypt(key, plaintext) { + const enc = new TextEncoder(); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const encrypted = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + key, enc.encode(plaintext) + ); + return { + ciphertext: btoa(String.fromCharCode(...new Uint8Array(encrypted))), + iv: btoa(String.fromCharCode(...iv)) + }; +} + +/** + * Decrypt ciphertext with AES-GCM + * @param {CryptoKey} key + * @param {string} ciphertextB64 - base64 ciphertext + * @param {string} ivB64 - base64 IV + * @returns {Promise} decrypted plaintext + */ +export async function decrypt(key, ciphertextB64, ivB64) { + const ciphertext = Uint8Array.from(atob(ciphertextB64), c => c.charCodeAt(0)); + const iv = Uint8Array.from(atob(ivB64), c => c.charCodeAt(0)); + const decrypted = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv }, + key, ciphertext + ); + return new TextDecoder().decode(decrypted); +} + +/** + * Generate write_hash for write authorization + * write_hash = SHA-256(password + username) — prevents unauthorized overwrites + * @param {string} password + * @param {string} username + * @returns {Promise} hex-encoded hash + */ +export async function generateWriteHash(password, username) { + const enc = new TextEncoder(); + const hash = await crypto.subtle.digest('SHA-256', enc.encode(password + username)); + return Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, '0')).join(''); +} diff --git a/www/js/elements.js b/www/js/elements.js index 9a8c45a..81037f8 100644 --- a/www/js/elements.js +++ b/www/js/elements.js @@ -37,6 +37,7 @@ export const elements = { konamiProgress: null, artworkContainer: null, artworkImage: null, + artworkCanvas: null, passwordContainer: null, passwordInput: null, passwordError: null, @@ -66,9 +67,48 @@ export const elements = { miniNextBtn: null, miniPlayerInfo: null, downloadIndicator: null, - syncBtn: null, + offlineCacheBtn: null, syncProgress: null, - repeatBtn: null + repeatBtn: null, + // Profile screen + profileNavBtn: null, + profileScreen: null, + profileBackBtn: null, + profileUsername: null, + profileListenTime: null, + profileHeard: null, + profileFavs: null, + profileLastPlayed: null, + profileSyncDot: null, + profileSyncStatus: null, + profileSyncDetail: null, + profileSyncBtn: null, + profileDisconnectBtn: null, + debugOutput: null, + // Circle progress + circleProgress: null, + circleMarks: null, + // Profile connect / sync info + profileCreds: null, + profileConnectBtn: null, + profileSyncInfo: null, + // Sync modal + syncModal: null, + syncModalBackdrop: null, + syncModalClose: null, + syncModalUsername: null, + syncModalPassword: null, + syncUsernameCount: null, + syncPasswordCount: null, + syncModalError: null, + syncModalSubmit: null, + // Debug strip + debugStrip: null, + debugStripCircle: null, + debugStripHeard: null, + debugStripScreen: null, + debugStripOnline: null, + debugMenu: null, }; /** @@ -105,6 +145,7 @@ export function initElements() { elements.konamiProgress = document.getElementById('konami-progress'); elements.artworkContainer = document.getElementById('artwork-container'); elements.artworkImage = document.getElementById('artwork-image'); + elements.artworkCanvas = document.getElementById('artwork-canvas'); elements.passwordContainer = document.getElementById('password-container'); elements.passwordInput = document.getElementById('password-input'); elements.passwordError = document.getElementById('password-error'); @@ -134,7 +175,41 @@ export function initElements() { elements.miniNextBtn = document.getElementById('mini-next-btn'); elements.miniPlayerInfo = document.getElementById('mini-player-info'); elements.downloadIndicator = document.getElementById('download-indicator'); - elements.syncBtn = document.getElementById('sync-btn'); + elements.offlineCacheBtn = document.getElementById('offline-cache-btn'); elements.syncProgress = document.getElementById('sync-progress'); elements.repeatBtn = document.getElementById('repeat-btn'); + elements.profileNavBtn = document.getElementById('profile-nav-btn'); + elements.profileScreen = document.getElementById('profile-screen'); + elements.profileBackBtn = document.getElementById('profile-back-btn'); + elements.profileUsername = document.getElementById('profile-username'); + elements.profileListenTime = document.getElementById('profile-listen-time'); + elements.profileHeard = document.getElementById('profile-heard'); + elements.profileFavs = document.getElementById('profile-favs'); + elements.profileLastPlayed = document.getElementById('profile-last-played'); + elements.profileSyncDot = document.getElementById('profile-sync-dot'); + elements.profileSyncStatus = document.getElementById('profile-sync-status'); + elements.profileSyncDetail = document.getElementById('profile-sync-detail'); + elements.profileSyncBtn = document.getElementById('profile-sync-btn'); + elements.profileDisconnectBtn = document.getElementById('profile-disconnect-btn'); + elements.debugOutput = document.getElementById('debug-output'); + elements.circleProgress = document.getElementById('circle-progress'); + elements.circleMarks = document.getElementById('circle-marks'); + elements.profileCreds = document.getElementById('profile-creds'); + elements.profileConnectBtn = document.getElementById('profile-connect-btn'); + elements.profileSyncInfo = document.getElementById('profile-sync-info'); + elements.syncModal = document.getElementById('sync-modal'); + elements.syncModalBackdrop = document.getElementById('sync-modal-backdrop'); + elements.syncModalClose = document.getElementById('sync-modal-close'); + elements.syncModalUsername = document.getElementById('sync-modal-username'); + elements.syncModalPassword = document.getElementById('sync-modal-password'); + elements.syncUsernameCount = document.getElementById('sync-username-count'); + elements.syncPasswordCount = document.getElementById('sync-password-count'); + elements.syncModalError = document.getElementById('sync-modal-error'); + elements.syncModalSubmit = document.getElementById('sync-modal-submit'); + elements.debugStrip = document.getElementById('debug-strip'); + elements.debugStripCircle = document.getElementById('debug-strip-circle'); + elements.debugStripHeard = document.getElementById('debug-strip-heard'); + elements.debugStripScreen = document.getElementById('debug-strip-screen'); + elements.debugStripOnline = document.getElementById('debug-strip-online'); + elements.debugMenu = document.getElementById('debug-menu'); } diff --git a/www/js/events.js b/www/js/events.js index 37163b0..c69074f 100644 --- a/www/js/events.js +++ b/www/js/events.js @@ -7,7 +7,7 @@ import { MODES } from './config.js'; import { SITE } from './site.config.js'; import { state, isSecretMode } from './state.js'; import { elements, initElements } from './elements.js'; -import { getSecretUnlocked, setSecretUnlocked } from './storage.js'; +import { getSecretUnlocked, setSecretUnlocked, saveListenStats } from './storage.js'; import { clearAllCookies } from './cookies.js'; import { trackEvent } from './analytics.js'; import { getTrackPathFromHash } from './hash.js'; @@ -23,7 +23,8 @@ import { handleBAInput, handleTouchStart, handleTouchEnd, - setStartPlayerFn + setStartPlayerFn, + showCashRain } from './konami.js'; import { startVoiceRecognition, setVoiceCallbacks } from './voice.js'; import { @@ -47,9 +48,12 @@ import { toggleFavoritesFilter, filterTracks, syncFavoritesCache, - cycleRepeatMode + cycleRepeatMode, + updateModeBasedUI } from './player.js'; import { initPWA, setOfflineChangeCallback } from './pwa.js'; +import { fullSync, getSyncCredentials, saveSyncCredentials, clearSyncCredentials, pullState } from './sync.js'; +import { getCurrentCircle, getUnlockedCircles, applyCircleTheme, getCircleIndex, CIRCLES } from './circles.js'; // Set up cross-module function references setStartPlayerFn(startPlayer); @@ -67,8 +71,10 @@ setAudioHandlers({ * @param {KeyboardEvent} e */ function handleKeydown(e) { - // Don't handle if typing in search - if (document.activeElement === elements.trackSearch) { + // Don't handle if typing in search or sync modal inputs + if (document.activeElement === elements.trackSearch || + document.activeElement === elements.syncModalUsername || + document.activeElement === elements.syncModalPassword) { return; } @@ -224,18 +230,18 @@ function setupPlayerSwipeHandlers() { * Setup long-press to download on artwork (desktop only) */ function setupArtworkLongPress() { - if (!elements.artworkImage) return; + if (!elements.artworkContainer) return; let pressTimer = null; - elements.artworkImage.addEventListener('mousedown', (e) => { + elements.artworkContainer.addEventListener('mousedown', (e) => { if (!isSecretMode() || !state.currentTrack) return; e.preventDefault(); - elements.artworkImage.classList.add('holding'); + elements.artworkContainer.classList.add('holding'); pressTimer = setTimeout(() => { state.artworkLongPressTriggered = true; - elements.artworkImage.classList.remove('holding'); + elements.artworkContainer.classList.remove('holding'); downloadTrack(state.currentTrack, 'long_press'); }, 1500); }); @@ -243,11 +249,11 @@ function setupArtworkLongPress() { function cancelPress() { clearTimeout(pressTimer); pressTimer = null; - elements.artworkImage.classList.remove('holding'); + elements.artworkContainer.classList.remove('holding'); } - elements.artworkImage.addEventListener('mouseup', cancelPress); - elements.artworkImage.addEventListener('mouseleave', cancelPress); + elements.artworkContainer.addEventListener('mouseup', cancelPress); + elements.artworkContainer.addEventListener('mouseleave', cancelPress); } /** @@ -557,6 +563,391 @@ function setupFirstInteractionUnlock() { }); } +/** + * Format seconds into human-readable time + */ +function formatListenTime(seconds) { + if (seconds < 60) return `${seconds}s`; + const hours = Math.floor(seconds / 3600); + const mins = Math.floor((seconds % 3600) / 60); + if (hours > 0) return `${hours}h ${mins}m`; + return `${mins}m`; +} + +/** + * Hash a string with SHA-256 (hex output) + */ +async function sha256(str) { + const encoded = new TextEncoder().encode(str); + const hash = await crypto.subtle.digest('SHA-256', encoded); + return Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, '0')).join(''); +} + +/** + * Check if a username matches the admin hash from site config + */ +async function checkAdmin(username) { + if (!SITE.admin || !username) return false; + const hash = await sha256(username); + return hash === SITE.admin; +} + +/** + * Activate admin mode — unlock everything, show debug strip + */ +function activateAdminMode() { + state.adminMode = true; + // Unlock secret mode + state.mode = MODES.SECRET; + state.secretUnlocked = true; + setSecretUnlocked(true); + // Unlock all circles + if (state.totalUniqueHeard < 999) { + state.totalUniqueHeard = 999; + state.currentCircle = 'treachery'; + applyCircleTheme('treachery'); + saveListenStats(); + } + // Show debug strip + if (elements.debugStrip) { + elements.debugStrip.classList.remove('hidden'); + } + updateDebugStrip(); + updateModeBasedUI(); +} + +/** + * Update debug strip with current state + */ +function updateDebugStrip() { + if (!state.adminMode || !elements.debugStrip) return; + if (elements.debugStripCircle) { + elements.debugStripCircle.textContent = state.currentCircle || 'limbo'; + } + if (elements.debugStripHeard) { + elements.debugStripHeard.textContent = 'heard:' + state.totalUniqueHeard; + } + if (elements.debugStripScreen) { + elements.debugStripScreen.textContent = state.currentScreen; + } + if (elements.debugStripOnline) { + elements.debugStripOnline.textContent = navigator.onLine ? 'online' : 'offline'; + } +} + +/** + * Populate profile screen with current stats and sync status + */ +function populateProfile() { + const creds = getSyncCredentials(); + + // Username + if (elements.profileUsername) { + elements.profileUsername.textContent = creds ? creds.username.toUpperCase() : 'GUEST'; + } + + // Stats + if (elements.profileListenTime) { + elements.profileListenTime.textContent = formatListenTime(state.totalListenSeconds); + } + if (elements.profileHeard) { + const heard = state.heardTracks.size; + const total = state.tracks.length; + const pct = total > 0 ? Math.round((heard / total) * 100) : 0; + elements.profileHeard.textContent = `${heard} / ${total} (${pct}%)`; + } + if (elements.profileFavs) { + elements.profileFavs.textContent = state.favoriteTracks.size.toString(); + } + if (elements.profileLastPlayed) { + if (state.lastPlayedAt) { + const d = new Date(state.lastPlayedAt); + elements.profileLastPlayed.textContent = d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + } else { + elements.profileLastPlayed.textContent = '---'; + } + } + + // Circle progress + if (elements.circleProgress && elements.circleMarks) { + const currentCircle = state.currentCircle || getCurrentCircle(state.totalUniqueHeard).id; + const unlocked = getUnlockedCircles(state.totalUniqueHeard); + const unlockedIds = new Set(unlocked.map(c => c.id)); + + elements.circleMarks.innerHTML = CIRCLES.map(circle => { + const isUnlocked = unlockedIds.has(circle.id); + const isCurrent = circle.id === currentCircle; + const classes = ['circle-mark']; + if (isUnlocked) classes.push('unlocked'); + if (isCurrent) classes.push('current'); + return `
`; + }).join(''); + + elements.circleProgress.classList.remove('hidden'); + + // Bind click handlers for unlocked marks + elements.circleMarks.querySelectorAll('.circle-mark.unlocked').forEach(mark => { + mark.addEventListener('click', () => { + const circleId = mark.dataset.circle; + state.currentCircle = circleId; + applyCircleTheme(circleId); + saveListenStats(); + populateProfile(); // Re-render to update current highlight + }); + }); + } + + // Sync section: show creds form or sync info + if (creds) { + // Connected — show sync info, hide creds form + if (elements.profileCreds) elements.profileCreds.classList.add('hidden'); + if (elements.profileSyncInfo) elements.profileSyncInfo.classList.remove('hidden'); + + // Sync status + if (elements.profileSyncDot) { + elements.profileSyncDot.className = 'sync-status-dot'; + elements.profileSyncDot.classList.add(state.lastSyncResult?.status === 'error' ? 'error' : 'connected'); + } + if (elements.profileSyncStatus) { + if (state.lastSyncResult?.status === 'error') { + elements.profileSyncStatus.textContent = 'Sync error'; + } else { + elements.profileSyncStatus.textContent = 'Connected'; + } + } + if (elements.profileSyncDetail) { + if (state.lastSyncResult?.error) { + elements.profileSyncDetail.textContent = state.lastSyncResult.error; + } else if (state.lastSyncResult?.status === 'ok') { + elements.profileSyncDetail.textContent = 'Last sync: ' + new Date().toLocaleTimeString(); + } else { + elements.profileSyncDetail.textContent = ''; + } + } + } else { + // Not connected — show creds form, hide sync info + if (elements.profileCreds) elements.profileCreds.classList.remove('hidden'); + if (elements.profileSyncInfo) elements.profileSyncInfo.classList.add('hidden'); + } + + // Show/hide sync actions based on connection state + if (elements.profileSyncBtn) { + elements.profileSyncBtn.textContent = 'SYNC NOW'; + } + if (elements.profileDisconnectBtn) { + elements.profileDisconnectBtn.style.display = ''; + } + + // DEBUG: populate debug info + populateDebugInfo(); + updateDebugStrip(); +} + +/** DEBUG: Remove before release */ +function populateDebugInfo() { + if (!elements.debugOutput) return; + const creds = getSyncCredentials(); + const debug = { + credentials: creds ? { username: creds.username, hasPassword: !!creds.password } : null, + syncResult: state.lastSyncResult, + stats: { + totalListenSeconds: state.totalListenSeconds, + totalUniqueHeard: state.totalUniqueHeard, + lastPlayedAt: state.lastPlayedAt, + currentCircle: state.currentCircle + }, + state: { + heardTracks: state.heardTracks.size, + favoriteTracks: state.favoriteTracks.size, + playHistory: state.playHistory.length, + historyIndex: state.historyIndex, + secretUnlocked: state.secretUnlocked, + currentScreen: state.currentScreen + }, + localStorage: { + keys: Object.keys(localStorage).length, + estimatedSize: JSON.stringify(localStorage).length + ' chars' + }, + network: { online: navigator.onLine }, + serviceWorker: { controlled: !!navigator.serviceWorker?.controller } + }; + elements.debugOutput.textContent = JSON.stringify(debug, null, 2); +} + + +/** + * Open the sync credentials modal + */ +function openSyncModal() { + if (!elements.syncModal) return; + // Reset form state + if (elements.syncModalUsername) elements.syncModalUsername.value = ''; + if (elements.syncModalPassword) elements.syncModalPassword.value = ''; + if (elements.syncModalError) { + elements.syncModalError.classList.add('hidden'); + elements.syncModalError.classList.remove('success'); + elements.syncModalError.textContent = ''; + } + if (elements.syncModalSubmit) { + elements.syncModalSubmit.textContent = 'CONNECT'; + elements.syncModalSubmit.classList.remove('syncing'); + } + if (elements.syncUsernameCount) elements.syncUsernameCount.textContent = '0/20'; + if (elements.syncPasswordCount) elements.syncPasswordCount.textContent = '0/20'; + // Remove any invalid state + elements.syncModalUsername?.classList.remove('invalid'); + elements.syncModalPassword?.classList.remove('invalid'); + elements.syncUsernameCount?.classList.remove('at-limit'); + elements.syncPasswordCount?.classList.remove('at-limit'); + + elements.syncModal.classList.remove('hidden'); + // Focus username after animation + setTimeout(() => elements.syncModalUsername?.focus(), 100); +} + +/** + * Close the sync credentials modal + */ +function closeSyncModal() { + if (elements.syncModal) elements.syncModal.classList.add('hidden'); +} + +/** + * Validate sync modal inputs. Returns error message or null. + */ +function validateSyncInputs(username, password) { + if (!username) return 'Username is required'; + if (!password) return 'Password is required'; + if (username.length > 20) return 'Username must be 20 characters or less'; + if (password.length > 20) return 'Password must be 20 characters or less'; + if (/\s/.test(username)) return 'Username cannot contain spaces'; + return null; +} + +/** + * Update character count display for an input + */ +function updateCharCount(input, countEl) { + if (!input || !countEl) return; + const len = input.value.length; + countEl.textContent = len + '/20'; + countEl.classList.toggle('at-limit', len >= 20); +} + +/** + * Setup sync modal event handlers + */ +function setupSyncModal() { + // Close handlers + if (elements.syncModalClose) { + elements.syncModalClose.addEventListener('click', closeSyncModal); + } + if (elements.syncModalBackdrop) { + elements.syncModalBackdrop.addEventListener('click', closeSyncModal); + } + + // Character count updates + if (elements.syncModalUsername) { + elements.syncModalUsername.addEventListener('input', () => { + updateCharCount(elements.syncModalUsername, elements.syncUsernameCount); + elements.syncModalUsername.classList.remove('invalid'); + if (elements.syncModalError) elements.syncModalError.classList.add('hidden'); + }); + } + if (elements.syncModalPassword) { + elements.syncModalPassword.addEventListener('input', () => { + updateCharCount(elements.syncModalPassword, elements.syncPasswordCount); + elements.syncModalPassword.classList.remove('invalid'); + if (elements.syncModalError) elements.syncModalError.classList.add('hidden'); + }); + } + + // Submit via Enter key + [elements.syncModalUsername, elements.syncModalPassword].forEach(input => { + if (input) { + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleSyncSubmit(); + } + }); + } + }); + + // Submit button + if (elements.syncModalSubmit) { + elements.syncModalSubmit.addEventListener('click', handleSyncSubmit); + } +} + +/** + * Handle sync modal form submission + */ +async function handleSyncSubmit() { + const username = elements.syncModalUsername?.value?.trim(); + const password = elements.syncModalPassword?.value; + + // Validate + const error = validateSyncInputs(username, password); + if (error) { + if (elements.syncModalError) { + elements.syncModalError.textContent = error; + elements.syncModalError.classList.remove('hidden', 'success'); + } + // Mark empty fields as invalid + if (!username) elements.syncModalUsername?.classList.add('invalid'); + if (!password) elements.syncModalPassword?.classList.add('invalid'); + return; + } + + // Disable form during submit + if (elements.syncModalSubmit) { + elements.syncModalSubmit.classList.add('syncing'); + elements.syncModalSubmit.textContent = 'CONNECTING...'; + } + + const result = await fullSync(username, password); + state.lastSyncResult = result; + + if (result.status === 'ok') { + saveSyncCredentials(username, password); + + // Show success briefly + if (elements.syncModalError) { + elements.syncModalError.textContent = 'Connected!'; + elements.syncModalError.classList.remove('hidden'); + elements.syncModalError.classList.add('success'); + } + if (elements.syncModalSubmit) { + elements.syncModalSubmit.textContent = 'CONNECTED'; + } + + // Check for admin mode + if (await checkAdmin(username)) { + activateAdminMode(); + } + if (result.pullResult?.details?.secretChanged) { + updateModeBasedUI(); + } + + // Close modal after brief success display + setTimeout(() => { + closeSyncModal(); + populateProfile(); + }, 800); + } else { + // Show error + if (elements.syncModalSubmit) { + elements.syncModalSubmit.classList.remove('syncing'); + elements.syncModalSubmit.textContent = 'CONNECT'; + } + if (elements.syncModalError) { + elements.syncModalError.textContent = result.error || 'Connection failed'; + elements.syncModalError.classList.remove('hidden', 'success'); + } + } +} + /** * Initialize the application */ @@ -679,8 +1070,103 @@ export function init() { } // Sync favorites offline button - if (elements.syncBtn) { - elements.syncBtn.addEventListener('click', syncFavoritesCache); + if (elements.offlineCacheBtn) { + elements.offlineCacheBtn.addEventListener('click', syncFavoritesCache); + } + + // Profile nav button — opens profile screen + if (elements.profileNavBtn) { + elements.profileNavBtn.addEventListener('click', () => { + populateProfile(); + showScreen('profile-screen'); + }); + } + + // Profile back button + if (elements.profileBackBtn) { + elements.profileBackBtn.addEventListener('click', () => { + showScreen('player-screen'); + }); + } + + // Profile sync button — force sync (only visible when connected) + if (elements.profileSyncBtn) { + elements.profileSyncBtn.addEventListener('click', async () => { + const creds = getSyncCredentials(); + if (!creds) return; // Shouldn't happen — button only visible when connected + + elements.profileSyncBtn.classList.add('syncing'); + elements.profileSyncBtn.textContent = 'SYNCING...'; + if (elements.profileSyncDot) elements.profileSyncDot.className = 'sync-status-dot syncing'; + + const result = await fullSync(creds.username, creds.password); + state.lastSyncResult = result; + + elements.profileSyncBtn.classList.remove('syncing'); + populateProfile(); + + if (result.status === 'ok' && result.pullResult?.details?.secretChanged) { + updateModeBasedUI(); + } + }); + } + + // Profile connect button — inline credential form + if (elements.profileConnectBtn) { + elements.profileConnectBtn.addEventListener('click', async () => { + const username = elements.profileCredsUsername?.value?.trim(); + const password = elements.profileCredsPassword?.value; + + if (!username || !password) return; + + elements.profileConnectBtn.classList.add('syncing'); + elements.profileConnectBtn.textContent = 'CONNECTING...'; + + const result = await fullSync(username, password); + state.lastSyncResult = result; + + elements.profileConnectBtn.classList.remove('syncing'); + elements.profileConnectBtn.textContent = 'CONNECT'; + + if (result.status === 'ok') { + saveSyncCredentials(username, password); + // Check for admin mode + if (await checkAdmin(username)) { + activateAdminMode(); + } + if (result.pullResult?.details?.secretChanged) { + updateModeBasedUI(); + } + } else { + // Show error inline — briefly flash the input border red + elements.profileCredsUsername.style.borderBottomColor = '#f87171'; + elements.profileCredsPassword.style.borderBottomColor = '#f87171'; + setTimeout(() => { + elements.profileCredsUsername.style.borderBottomColor = ''; + elements.profileCredsPassword.style.borderBottomColor = ''; + }, 2000); + return; + } + + // Clear inputs + if (elements.profileCredsUsername) elements.profileCredsUsername.value = ''; + if (elements.profileCredsPassword) elements.profileCredsPassword.value = ''; + + populateProfile(); + }); + } + + // Profile disconnect button + if (elements.profileDisconnectBtn) { + elements.profileDisconnectBtn.addEventListener('click', () => { + if (!confirm('Disconnect sync? Your data stays on this device.')) return; + clearSyncCredentials(); + state.lastSyncResult = null; + state.adminMode = false; + if (elements.debugStrip) elements.debugStrip.classList.add('hidden'); + if (elements.debugMenu) elements.debugMenu.classList.add('hidden'); + populateProfile(); + }); } // Repeat button @@ -738,4 +1224,97 @@ export function init() { if (el) el.classList.toggle('hidden', !offline); filterTracks(state.searchQuery); }); + + // Auto-sync on load if credentials exist + const syncCreds = getSyncCredentials(); + if (syncCreds) { + pullState(syncCreds.username, syncCreds.password).then(result => { + state.lastSyncResult = result; + if (result.status === 'merged' && result.details) { + if (result.details.secretChanged) { + updateModeBasedUI(); + } + if (result.details.favoritesAdded > 0) { + if (typeof renderTrackList === 'function') renderTrackList(); + } + } + }).catch(() => {}); + } + + // Activate admin mode if logged in as admin + const syncCredsAdmin = getSyncCredentials(); + if (syncCredsAdmin) { + checkAdmin(syncCredsAdmin.username).then(isAdminUser => { + if (isAdminUser) activateAdminMode(); + }); + } + + // Debug strip toggle + if (elements.debugStrip) { + elements.debugStrip.addEventListener('click', () => { + if (elements.debugMenu) { + elements.debugMenu.classList.toggle('hidden'); + } + }); + } + + // Debug menu actions + if (elements.debugMenu) { + // Heard count buttons + elements.debugMenu.querySelectorAll('.debug-action[data-heard]').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const value = parseInt(btn.dataset.heard); + state.totalUniqueHeard = value; + const circle = getCurrentCircle(value); + state.currentCircle = circle.id; + applyCircleTheme(circle.id); + saveListenStats(); + updateDebugStrip(); + populateProfile(); + }); + }); + + // Cash rain + const cashRainBtn = document.getElementById('debug-cash-rain'); + if (cashRainBtn) { + cashRainBtn.addEventListener('click', (e) => { + e.stopPropagation(); + showCashRain(); + elements.debugMenu.classList.add('hidden'); + }); + } + + // Force sync + const forceSyncBtn = document.getElementById('debug-force-sync'); + if (forceSyncBtn) { + forceSyncBtn.addEventListener('click', async (e) => { + e.stopPropagation(); + const creds = getSyncCredentials(); + if (creds) { + forceSyncBtn.textContent = 'SYNCING...'; + const result = await fullSync(creds.username, creds.password); + state.lastSyncResult = result; + forceSyncBtn.textContent = result.status === 'ok' ? 'SYNCED!' : 'ERROR'; + setTimeout(() => { forceSyncBtn.textContent = 'FORCE SYNC'; }, 1500); + updateDebugStrip(); + } + }); + } + + // Toggle secret + const toggleSecretBtn = document.getElementById('debug-toggle-secret'); + if (toggleSecretBtn) { + toggleSecretBtn.addEventListener('click', (e) => { + e.stopPropagation(); + state.secretUnlocked = !state.secretUnlocked; + state.mode = state.secretUnlocked ? MODES.SECRET : MODES.REGULAR; + setSecretUnlocked(state.secretUnlocked); + updateModeBasedUI(); + toggleSecretBtn.textContent = state.secretUnlocked ? 'SECRET: ON' : 'SECRET: OFF'; + setTimeout(() => { toggleSecretBtn.textContent = 'TOGGLE SECRET'; }, 1000); + updateDebugStrip(); + }); + } + } } diff --git a/www/js/genart.js b/www/js/genart.js new file mode 100644 index 0000000..406f9a4 --- /dev/null +++ b/www/js/genart.js @@ -0,0 +1,97 @@ +/** + * Generative artwork for tracks without cover art + * @module genart + */ + +let animationId = null; +let currentCanvas = null; + +/** + * Simple string hash → number + */ +function hash(str) { + let h = 0; + for (let i = 0; i < str.length; i++) { + h = ((h << 5) - h + str.charCodeAt(i)) | 0; + } + return Math.abs(h); +} + +/** + * Derive HSL color from hash with constrained saturation/lightness + */ +function hashColor(seed, offset) { + const hue = (seed + offset * 137) % 360; + const sat = 30 + (seed + offset * 53) % 40; // 30-70% + const lit = 15 + (seed + offset * 29) % 25; // 15-40% — keep dark + return `hsl(${hue}, ${sat}%, ${lit}%)`; +} + +/** + * Start generative art animation on a canvas + * @param {HTMLCanvasElement} canvas + * @param {Object} track - Track object with title, artist, album + */ +export function startGenArt(canvas, track) { + stopGenArt(); + currentCanvas = canvas; + + const ctx = canvas.getContext('2d'); + const size = canvas.width; + const seed = hash(`${track.title || ''}${track.artist || ''}${track.album || ''}`); + + // Derive 3 colors and blob parameters from seed + const colors = [hashColor(seed, 0), hashColor(seed, 1), hashColor(seed, 2)]; + const blobCount = 3 + (seed % 3); // 3-5 blobs + const blobs = []; + + for (let i = 0; i < blobCount; i++) { + const s = seed + i * 1000; + blobs.push({ + cx: (s % 100) / 100, // center x (0-1) + cy: ((s >> 3) % 100) / 100, // center y (0-1) + rx: 0.15 + ((s >> 6) % 30) / 100, // radius x + ry: 0.15 + ((s >> 9) % 30) / 100, // radius y + speed: 0.0003 + ((s >> 12) % 10) / 30000, // animation speed + phase: ((s >> 15) % 100) / 100 * Math.PI * 2, + color: colors[i % colors.length] + }); + } + + function draw(time) { + ctx.fillStyle = '#000'; + ctx.fillRect(0, 0, size, size); + + for (const blob of blobs) { + const t = time * blob.speed + blob.phase; + const x = (blob.cx + Math.sin(t) * 0.15) * size; + const y = (blob.cy + Math.cos(t * 0.7) * 0.15) * size; + const rx = blob.rx * size * (0.8 + Math.sin(t * 1.3) * 0.2); + const ry = blob.ry * size * (0.8 + Math.cos(t * 0.9) * 0.2); + + const gradient = ctx.createRadialGradient(x, y, 0, x, y, Math.max(rx, ry)); + gradient.addColorStop(0, blob.color); + gradient.addColorStop(1, 'transparent'); + + ctx.fillStyle = gradient; + ctx.beginPath(); + ctx.ellipse(x, y, rx, ry, t * 0.5, 0, Math.PI * 2); + ctx.fill(); + } + + animationId = requestAnimationFrame(draw); + } + + animationId = requestAnimationFrame(draw); +} + +/** + * Stop generative art animation + */ +export function stopGenArt() { + if (animationId) { + cancelAnimationFrame(animationId); + animationId = null; + } + currentCanvas = null; +} diff --git a/www/js/konami.js b/www/js/konami.js index fbf4561..2a7b3e2 100644 --- a/www/js/konami.js +++ b/www/js/konami.js @@ -122,40 +122,58 @@ function fireKonamiReward() { } /** - * Show cash rain animation + * Show cash rain animation — deep 3D pour */ export function showCashRain() { const overlay = document.createElement('div'); overlay.className = 'cash-rain'; - // $100 bill textures const billImages = ['/img/benj_front.jpeg', '/img/benj_back.jpeg']; - const sizes = [ - { w: 50, h: 21 }, - { w: 70, h: 30 }, - { w: 90, h: 38 } + + // Size tiers for parallax depth (small = far away, large = close) + const sizeTiers = [ + { w: 30, h: 13, opacity: 0.4, zIndex: 1 }, // far background + { w: 45, h: 19, opacity: 0.6, zIndex: 2 }, // mid-back + { w: 60, h: 25, opacity: 0.75, zIndex: 3 }, // middle + { w: 80, h: 34, opacity: 0.9, zIndex: 4 }, // mid-front + { w: 100, h: 42, opacity: 1, zIndex: 5 }, // foreground ]; - const numBills = 80; - // Create 3 waves of falling bills - for (let wave = 0; wave < 3; wave++) { - const waveDelay = wave * 0.4; - const billsInWave = Math.floor(numBills / 3); + const totalBills = 150; + const numWaves = 5; + const billsPerWave = Math.floor(totalBills / numWaves); + + for (let wave = 0; wave < numWaves; wave++) { + const waveDelay = wave * 0.3; - for (let i = 0; i < billsInWave; i++) { + for (let i = 0; i < billsPerWave; i++) { const bill = document.createElement('div'); bill.className = 'bill'; - const size = sizes[Math.floor(Math.random() * sizes.length)]; + // Weight distribution: more small bills (far) than large (close) + const tierRoll = Math.random(); + let tier; + if (tierRoll < 0.3) tier = sizeTiers[0]; // 30% tiny + else if (tierRoll < 0.55) tier = sizeTiers[1]; // 25% small + else if (tierRoll < 0.75) tier = sizeTiers[2]; // 20% medium + else if (tierRoll < 0.9) tier = sizeTiers[3]; // 15% large + else tier = sizeTiers[4]; // 10% huge + const img = billImages[Math.floor(Math.random() * billImages.length)]; - bill.style.setProperty('--bill-width', size.w + 'px'); - bill.style.setProperty('--bill-height', size.h + 'px'); + bill.style.setProperty('--bill-width', tier.w + 'px'); + bill.style.setProperty('--bill-height', tier.h + 'px'); bill.style.setProperty('--bill-img', `url(${img})`); + bill.style.setProperty('--bill-opacity', tier.opacity); + bill.style.zIndex = tier.zIndex; bill.style.left = (-10 + Math.random() * 120) + 'vw'; - bill.style.setProperty('--fall-duration', (2 + Math.random() * 1.5) + 's'); + + // Slower fall for small (far) bills, faster for large (close) + const baseDuration = 2 + (5 - tier.zIndex) * 0.4; + bill.style.setProperty('--fall-duration', (baseDuration + Math.random() * 1.5) + 's'); bill.style.setProperty('--fall-delay', (waveDelay + Math.random() * 0.5) + 's'); - // Random spin amounts + + // Random 3D tumble bill.style.setProperty('--spin-x', (360 + Math.random() * 720) * (Math.random() > 0.5 ? 1 : -1) + 'deg'); bill.style.setProperty('--spin-y', (360 + Math.random() * 720) * (Math.random() > 0.5 ? 1 : -1) + 'deg'); bill.style.setProperty('--spin-z', (180 + Math.random() * 360) * (Math.random() > 0.5 ? 1 : -1) + 'deg'); @@ -166,7 +184,7 @@ export function showCashRain() { } document.body.appendChild(overlay); - setTimeout(() => overlay.remove(), 3000); + setTimeout(() => overlay.remove(), 4500); } /** diff --git a/www/js/player.js b/www/js/player.js index f294a6e..f4f9e43 100644 --- a/www/js/player.js +++ b/www/js/player.js @@ -9,7 +9,7 @@ import { elements } from './elements.js'; import { formatTime, getMediaUrl } from './utils.js'; import { trackEvent } from './analytics.js'; import { setSignedCookies, clearAllCookies } from './cookies.js'; -import { loadHeardTracks, loadFavoriteTracks, saveFavoriteTracks, setSecretUnlocked } from './storage.js'; +import { loadHeardTracks, loadFavoriteTracks, saveFavoriteTracks, setSecretUnlocked, savePlayHistory, loadPlayHistory, saveListenStats, loadListenStats } from './storage.js'; import { setTrackInHash } from './hash.js'; import { showScreen, showError, showAuthError, updateMiniPlayer, updateModeBasedUI } from './ui.js'; import { getCachedAudio, getCachedTrackIds, removeCachedAudio, syncFavoritesOffline, clearCache } from './cache.js'; @@ -31,6 +31,9 @@ import { setPlayTrackFn, setUpdateCatalogProgressFn } from './tracks.js'; +import { debouncedPush } from './sync.js'; +import { applyCircleTheme, getCurrentCircle } from './circles.js'; +import { startGenArt, stopGenArt } from './genart.js'; // Track current blob URL for cleanup let currentBlobUrl = null; @@ -115,6 +118,7 @@ export function updateArtwork(track) { if (!elements.artworkContainer || !elements.artworkImage) return; if (track.artwork) { + stopGenArt(); elements.artworkImage.src = getMediaUrl(track.artwork); elements.artworkImage.alt = `${track.artist || 'Unknown'} - ${track.album || 'Unknown'}`; elements.artworkContainer.classList.remove('no-art'); @@ -122,6 +126,10 @@ export function updateArtwork(track) { elements.artworkImage.src = ''; elements.artworkImage.alt = ''; elements.artworkContainer.classList.add('no-art'); + // Start generative art placeholder + if (elements.artworkCanvas) { + startGenArt(elements.artworkCanvas, track); + } } } @@ -164,7 +172,7 @@ function searchFor(query) { * Setup clickable metadata for search */ function setupClickableMetadata() { - const clickables = [elements.artist, elements.artworkImage]; + const clickables = [elements.artist, elements.artworkContainer]; if (isSecretMode()) { // Add clickable class and handlers in secret mode @@ -172,8 +180,8 @@ function setupClickableMetadata() { if (el) el.classList.add('clickable'); }); elements.artist.onclick = () => searchFor(state.currentTrack?.artist); - if (elements.artworkImage) { - elements.artworkImage.onclick = () => { + if (elements.artworkContainer) { + elements.artworkContainer.onclick = () => { if (state.artworkLongPressTriggered) { state.artworkLongPressTriggered = false; return; @@ -213,6 +221,7 @@ export async function playTrack(track, fromHistory = false, isRetry = false) { } state.playHistory.push(track.id); state.historyIndex = state.playHistory.length - 1; + savePlayHistory(); // Reset retry count for new track state.currentRetryAttempts = 0; } @@ -255,6 +264,10 @@ export async function playTrack(track, fromHistory = false, isRetry = false) { state.isPlaying = true; elements.playPauseBtn.classList.remove('paused'); + // Track listen stats + state.lastPlayedAt = new Date().toISOString(); + state._playStartTime = Date.now(); + saveListenStats(); markTrackHeard(track.id); // Reset error counts on successful playback @@ -323,6 +336,7 @@ setUpdateCatalogProgressFn(updateCatalogProgress); export function playPreviousTrack() { if (state.historyIndex > 0) { state.historyIndex--; + savePlayHistory(); const trackId = state.playHistory[state.historyIndex]; const track = state.tracks.find(t => t.id === trackId); if (track) { @@ -338,6 +352,7 @@ export function playNextTrack() { // If there's forward history, use it if (state.historyIndex < state.playHistory.length - 1) { state.historyIndex++; + savePlayHistory(); const trackId = state.playHistory[state.historyIndex]; const track = state.tracks.find(t => t.id === trackId); if (track) { @@ -426,6 +441,7 @@ export function handlePlayPause() { playPromise.then(() => { elements.playPauseBtn.classList.remove('paused'); state.isPlaying = true; + state._playStartTime = Date.now(); trackEvent('resume'); updateMiniPlayer(); }).catch((e) => { @@ -439,6 +455,12 @@ export function handlePlayPause() { }); } } else { + // Accumulate listen time on pause + if (state._playStartTime) { + state.totalListenSeconds += Math.floor((Date.now() - state._playStartTime) / 1000); + state._playStartTime = null; + saveListenStats(); + } elements.audio.pause(); elements.playPauseBtn.classList.add('paused'); state.isPlaying = false; @@ -517,6 +539,12 @@ export function updateRepeatButton() { * Handle track ended event */ export function handleTrackEnded() { + // Accumulate listen time + if (state._playStartTime) { + state.totalListenSeconds += Math.floor((Date.now() - state._playStartTime) / 1000); + state._playStartTime = null; + saveListenStats(); + } // Track completed listen if (state.currentTrack) { trackEvent('song_complete', { @@ -617,6 +645,12 @@ export async function startPlayer() { await loadManifest(); loadHeardTracks(); loadFavoriteTracks(); + loadPlayHistory(); + loadListenStats(); + // Restore saved circle theme + const circleToApply = state.currentCircle || getCurrentCircle(state.totalUniqueHeard).id; + state.currentCircle = circleToApply; + applyCircleTheme(circleToApply); updateCatalogProgress(); // Load cached track IDs for offline indicators @@ -688,6 +722,7 @@ export function toggleFavorite() { trackEvent('favorite', { track_id: id, artist: state.currentTrack.artist, title: state.currentTrack.title }); } saveFavoriteTracks(); + debouncedPush(); updateFavoriteButton(); updateSyncUI(); renderTrackList(); @@ -720,13 +755,13 @@ export function toggleFavoritesFilter() { * Update sync button and progress bar UI */ export function updateSyncUI() { - if (!elements.syncBtn) return; + if (!elements.offlineCacheBtn) return; const allCached = state.favoriteTracks.size > 0 && [...state.favoriteTracks].every(id => state.cachedTracks.has(id)); - elements.syncBtn.classList.toggle('syncing', state.cacheSyncing); - elements.syncBtn.classList.toggle('synced', allCached && !state.cacheSyncing); + elements.offlineCacheBtn.classList.toggle('syncing', state.cacheSyncing); + elements.offlineCacheBtn.classList.toggle('synced', allCached && !state.cacheSyncing); if (elements.syncProgress) { if (state.cacheSyncing && state.cacheSyncProgress) { diff --git a/www/js/pwa.js b/www/js/pwa.js index f942b9a..4b738c4 100644 --- a/www/js/pwa.js +++ b/www/js/pwa.js @@ -30,7 +30,11 @@ export function setOfflineChangeCallback(fn) { */ export function isTrackCached(track) { if (!track || !track.path) return false; - return cachedTracks.has(getMediaUrl(track.path)); + // Check SW Cache API (auto-cached on play) + if (cachedTracks.has(getMediaUrl(track.path))) return true; + // Check IndexedDB cache (manually synced via offline cache button) + if (state.cachedTracks && state.cachedTracks.has(track.id)) return true; + return false; } /** diff --git a/www/js/state.js b/www/js/state.js index 7e2d4a3..8334f25 100644 --- a/www/js/state.js +++ b/www/js/state.js @@ -21,7 +21,8 @@ export const SCREENS = { ENTER: 'enter', PLAYER: 'player', SEARCH: 'search', - ERROR: 'error' + ERROR: 'error', + PROFILE: 'profile' }; /** @@ -75,7 +76,17 @@ export const state = { cacheSyncing: false, cacheSyncProgress: null, // Repeat mode - repeatMode: 'off' + repeatMode: 'off', + // Listening stats (cumulative, never reset) + totalListenSeconds: 0, + totalUniqueHeard: 0, + lastPlayedAt: null, + currentCircle: null, + // Admin/debug mode + adminMode: false, + // Sync tracking + lastSyncResult: null, + _playStartTime: null, }; /** diff --git a/www/js/storage.js b/www/js/storage.js index e264e56..78365b4 100644 --- a/www/js/storage.js +++ b/www/js/storage.js @@ -94,3 +94,67 @@ export function saveFavoriteTracks() { console.warn('Failed to save favorite tracks:', e); } } + +/** + * Save play history to localStorage + */ +export function savePlayHistory() { + try { + localStorage.setItem(CONFIG.STORAGE_KEY.replace('heard_tracks', 'play_history'), + JSON.stringify({ history: state.playHistory, index: state.historyIndex })); + } catch (e) { + console.warn('Failed to save play history:', e); + } +} + +/** + * Load play history from localStorage + */ +export function loadPlayHistory() { + try { + const stored = localStorage.getItem(CONFIG.STORAGE_KEY.replace('heard_tracks', 'play_history')); + if (stored) { + const { history, index } = JSON.parse(stored); + if (Array.isArray(history)) { + state.playHistory = history; + state.historyIndex = typeof index === 'number' ? index : history.length - 1; + } + } + } catch (e) { + console.warn('Failed to load play history:', e); + } +} + +/** + * Save listening stats to localStorage + */ +export function saveListenStats() { + try { + localStorage.setItem(CONFIG.STATS_KEY, JSON.stringify({ + totalListenSeconds: state.totalListenSeconds, + totalUniqueHeard: state.totalUniqueHeard, + lastPlayedAt: state.lastPlayedAt, + currentCircle: state.currentCircle + })); + } catch (e) { + console.warn('Failed to save listen stats:', e); + } +} + +/** + * Load listening stats from localStorage + */ +export function loadListenStats() { + try { + const stored = localStorage.getItem(CONFIG.STATS_KEY); + if (stored) { + const data = JSON.parse(stored); + state.totalListenSeconds = data.totalListenSeconds || 0; + state.totalUniqueHeard = data.totalUniqueHeard || 0; + state.lastPlayedAt = data.lastPlayedAt || null; + state.currentCircle = data.currentCircle || null; + } + } catch (e) { + console.warn('Failed to load listen stats:', e); + } +} diff --git a/www/js/sync.js b/www/js/sync.js new file mode 100644 index 0000000..a0e5849 --- /dev/null +++ b/www/js/sync.js @@ -0,0 +1,245 @@ +/** + * State sync — push/pull encrypted state to/from cloud + * @module sync + */ + +import { state } from './state.js'; +import { CONFIG } from './config.js'; +import { saveFavoriteTracks, saveHeardTracks, setSecretUnlocked, savePlayHistory, saveListenStats } from './storage.js'; +import { deriveKey, encrypt, decrypt, generateWriteHash } from './crypto.js'; +import { getCircleIndex } from './circles.js'; + +const SYNC_ENDPOINT = '/sync'; +const SYNC_CREDS_KEY = `${CONFIG.STORAGE_KEY.replace('_heard_tracks', '')}_sync_credentials`; + +let syncDebounceTimer = null; + +/** + * Save sync credentials in localStorage + * @param {string} username + * @param {string} password + */ +export function saveSyncCredentials(username, password) { + try { + localStorage.setItem(SYNC_CREDS_KEY, JSON.stringify({ username, password })); + } catch (e) { /* ignore */ } +} + +/** + * Get stored sync credentials + * @returns {{username: string, password: string}|null} + */ +export function getSyncCredentials() { + try { + const stored = localStorage.getItem(SYNC_CREDS_KEY); + if (stored) return JSON.parse(stored); + } catch (e) { /* ignore */ } + return null; +} + +/** + * Clear sync credentials + */ +export function clearSyncCredentials() { + try { localStorage.removeItem(SYNC_CREDS_KEY); } catch (e) { /* ignore */ } +} + +/** + * Serialize syncable state to JSON + * @returns {string} + */ +function serializeState() { + return JSON.stringify({ + favoriteTracks: [...state.favoriteTracks], + heardTracks: [...state.heardTracks], + secretUnlocked: state.secretUnlocked, + playHistory: state.playHistory, + historyIndex: state.historyIndex, + totalListenSeconds: state.totalListenSeconds, + totalUniqueHeard: state.totalUniqueHeard, + lastPlayedAt: state.lastPlayedAt, + currentCircle: state.currentCircle, + syncedAt: new Date().toISOString() + }); +} + +/** + * Merge remote state into local state (union merge) + * @param {Object} remote - Deserialized remote state + * @returns {{favoritesAdded: number, heardAdded: number, secretChanged: boolean}} + */ +function mergeState(remote) { + let favoritesAdded = 0; + let heardAdded = 0; + let secretChanged = false; + + if (Array.isArray(remote.favoriteTracks)) { + for (const id of remote.favoriteTracks) { + if (!state.favoriteTracks.has(id)) { + state.favoriteTracks.add(id); + favoritesAdded++; + } + } + if (favoritesAdded > 0) saveFavoriteTracks(); + } + + if (Array.isArray(remote.heardTracks)) { + for (const id of remote.heardTracks) { + if (!state.heardTracks.has(id)) { + state.heardTracks.add(id); + heardAdded++; + } + } + if (heardAdded > 0) saveHeardTracks(); + } + + if (remote.secretUnlocked && !state.secretUnlocked) { + state.secretUnlocked = true; + state.mode = 'secret'; + setSecretUnlocked(true); + secretChanged = true; + } + + if (Array.isArray(remote.playHistory) && remote.playHistory.length > state.playHistory.length) { + state.playHistory = remote.playHistory; + state.historyIndex = typeof remote.historyIndex === 'number' ? remote.historyIndex : remote.playHistory.length - 1; + savePlayHistory(); + } + + // Stats: take max for counters, most recent for timestamps + let statsChanged = false; + if (typeof remote.totalListenSeconds === 'number' && remote.totalListenSeconds > state.totalListenSeconds) { + state.totalListenSeconds = remote.totalListenSeconds; + statsChanged = true; + } + if (typeof remote.totalUniqueHeard === 'number' && remote.totalUniqueHeard > state.totalUniqueHeard) { + state.totalUniqueHeard = remote.totalUniqueHeard; + statsChanged = true; + } + if (remote.lastPlayedAt && (!state.lastPlayedAt || remote.lastPlayedAt > state.lastPlayedAt)) { + state.lastPlayedAt = remote.lastPlayedAt; + statsChanged = true; + } + if (statsChanged) saveListenStats(); + + // Circle: take the more advanced circle (higher threshold) + if (remote.currentCircle) { + const remoteIdx = getCircleIndex(remote.currentCircle); + const localIdx = getCircleIndex(state.currentCircle || 'limbo'); + if (remoteIdx > localIdx) { + state.currentCircle = remote.currentCircle; + saveListenStats(); + } + } + + return { favoritesAdded, heardAdded, secretChanged, statsChanged }; +} + +/** + * Pull remote state and merge into local + * @param {string} username + * @param {string} password + * @returns {Promise<{status: 'merged'|'empty'|'error', details?: Object, error?: string}>} + */ +export async function pullState(username, password) { + try { + const response = await fetch(`${SYNC_ENDPOINT}/${encodeURIComponent(username)}`); + + if (!response.ok) { + return { status: 'error', error: `Server error: ${response.status}` }; + } + + const data = await response.json(); + + // Lambda returns 200 for everything to avoid CloudFront error page interception + if (data.error) { + return { status: 'error', error: data.error }; + } + if (data.found === false) { + return { status: 'empty' }; + } + + const key = await deriveKey(password, username); + + let plaintext; + try { + plaintext = await decrypt(key, data.ciphertext, data.iv); + } catch (e) { + return { status: 'error', error: 'Wrong password' }; + } + + const remote = JSON.parse(plaintext); + const details = mergeState(remote); + return { status: 'merged', details }; + } catch (e) { + console.error('Pull failed:', e); + return { status: 'error', error: e.message }; + } +} + +/** + * Push local state to server (full replace) + * @param {string} username + * @param {string} password + * @returns {Promise<{status: 'ok'|'error', error?: string}>} + */ +export async function pushState(username, password) { + try { + const key = await deriveKey(password, username); + const plaintext = serializeState(); + const { ciphertext, iv } = await encrypt(key, plaintext); + const write_hash = await generateWriteHash(password, username); + + const response = await fetch(`${SYNC_ENDPOINT}/${encodeURIComponent(username)}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ciphertext, iv, salt: username, write_hash }) + }); + + if (!response.ok) { + return { status: 'error', error: `Server error: ${response.status}` }; + } + + const data = await response.json(); + if (data.error) { + return { status: 'error', error: data.error }; + } + + return { status: 'ok' }; + } catch (e) { + console.error('Push failed:', e); + return { status: 'error', error: e.message }; + } +} + +/** + * Full sync: pull (merge), then push (replace with merged state) + * @param {string} username + * @param {string} password + * @returns {Promise<{status: 'ok'|'error', pullResult?: Object, error?: string}>} + */ +export async function fullSync(username, password) { + const pullResult = await pullState(username, password); + if (pullResult.status === 'error') return pullResult; + + const pushResult = await pushState(username, password); + if (pushResult.status === 'error') return pushResult; + + return { status: 'ok', pullResult }; +} + +/** + * Debounced push — called after state mutations (favorite toggle, heard track) + * Only pushes if sync credentials exist. 2-second debounce. + */ +export function debouncedPush() { + const creds = getSyncCredentials(); + if (!creds) return; + + if (syncDebounceTimer) clearTimeout(syncDebounceTimer); + syncDebounceTimer = setTimeout(() => { + pushState(creds.username, creds.password).catch(e => { + console.warn('Auto-push failed:', e); + }); + }, 2000); +} diff --git a/www/js/tracks.js b/www/js/tracks.js index 1e7d46a..affcb2a 100644 --- a/www/js/tracks.js +++ b/www/js/tracks.js @@ -6,10 +6,12 @@ import { state, isSecretMode, REPEAT_MODES } from './state.js'; import { elements } from './elements.js'; import { seededRandom, escapeHtml, getMediaUrl } from './utils.js'; -import { saveHeardTracks } from './storage.js'; +import { saveHeardTracks, saveListenStats } from './storage.js'; +import { debouncedPush } from './sync.js'; import { trackEvent } from './analytics.js'; import { showAuthError } from './ui.js'; import { networkState, isTrackCached } from './pwa.js'; +import { checkCircleAdvancement, applyCircleTheme } from './circles.js'; // Forward declaration for renderTrackList callback let updateCatalogProgressFn = null; @@ -82,6 +84,7 @@ export function getNextTrack() { // Clear only album track IDs from heardTracks, then loop pool.forEach(t => state.heardTracks.delete(t.id)); saveHeardTracks(); + debouncedPush(); return pool[seededRandom(pool.length)]; } return unheard[seededRandom(unheard.length)]; @@ -94,6 +97,7 @@ export function getNextTrack() { if (unheard.length === 0) { state.heardTracks.clear(); saveHeardTracks(); + debouncedPush(); return pool[seededRandom(pool.length)]; } @@ -105,8 +109,30 @@ export function getNextTrack() { * @param {string} trackId - Track ID */ export function markTrackHeard(trackId) { + // Track cumulative unique heard (doesn't reset like heardTracks) + const wasNew = !state.heardTracks.has(trackId); + if (wasNew) { + const prevHeard = state.totalUniqueHeard; + state.totalUniqueHeard++; + + // Check for circle advancement + const newCircle = checkCircleAdvancement(prevHeard, state.totalUniqueHeard); + if (newCircle) { + state.currentCircle = newCircle.id; + applyCircleTheme(newCircle.id); + saveListenStats(); + // Pulse the profile nav button + if (elements.profileNavBtn) { + elements.profileNavBtn.classList.add('circle-advance'); + setTimeout(() => { + elements.profileNavBtn.classList.remove('circle-advance'); + }, 2000); + } + } + } state.heardTracks.add(trackId); saveHeardTracks(); + debouncedPush(); if (updateCatalogProgressFn) { updateCatalogProgressFn(); } diff --git a/www/js/ui.js b/www/js/ui.js index 6eeb17b..c3e73a1 100644 --- a/www/js/ui.js +++ b/www/js/ui.js @@ -13,6 +13,7 @@ const SCREEN_ID_MAP = { 'enter-screen': SCREENS.ENTER, 'player-screen': SCREENS.PLAYER, 'search-screen': SCREENS.SEARCH, + 'profile-screen': SCREENS.PROFILE, 'error-screen': SCREENS.ERROR }; @@ -46,8 +47,8 @@ export function updateModeBasedUI() { } // Sync offline button: secret only - if (elements.syncBtn) { - elements.syncBtn.classList.toggle('hidden', !isSecret); + if (elements.offlineCacheBtn) { + elements.offlineCacheBtn.classList.toggle('hidden', !isSecret); } // Search trigger: secret only @@ -95,7 +96,7 @@ export function showScreen(screenId, pushHistory = true) { // Animate title position based on screen const titleLogo = document.getElementById('title-logo'); if (titleLogo) { - if (screenId === 'player-screen' || screenId === 'search-screen') { + if (screenId === 'player-screen' || screenId === 'search-screen' || screenId === 'profile-screen') { titleLogo.classList.add('at-top'); } else { titleLogo.classList.remove('at-top'); diff --git a/www/main.css b/www/main.css index f8211ea..3d77941 100644 --- a/www/main.css +++ b/www/main.css @@ -48,7 +48,17 @@ html { body { background-color: var(--bg); - background-image: none; + background-image: + radial-gradient(ellipse at 30% 20%, rgba(80, 0, 0, 0.15) 0%, transparent 50%), + radial-gradient(ellipse at 70% 80%, rgba(40, 0, 60, 0.12) 0%, transparent 50%), + radial-gradient(ellipse at center, #1a1a1a 0%, #000 70%), + repeating-linear-gradient( + 0deg, + transparent, + transparent 2px, + rgba(255,255,255,0.015) 2px, + rgba(255,255,255,0.015) 4px + ); color: var(--fg); font-family: var(--font); font-size: 14px; @@ -56,7 +66,53 @@ body { position: relative; } -/* Background effects (customize for your theme) */ +/* Smoke background — colors driven by CSS custom properties for circle themes */ +body::before, +body::after { + content: ''; + position: fixed; + inset: -50%; + pointer-events: none; + z-index: -1; +} + +body::before { + background: + radial-gradient(ellipse 80% 50% at 20% 80%, var(--smoke-1, rgba(255,255,255,0.25)) 0%, transparent 50%), + radial-gradient(ellipse 60% 40% at 80% 20%, var(--smoke-2, rgba(255,255,255,0.22)) 0%, transparent 50%), + radial-gradient(ellipse 50% 60% at 50% 50%, var(--smoke-3, rgba(255,255,255,0.18)) 0%, transparent 40%); + animation: smoke1 12s ease-in-out infinite; + filter: blur(50px); +} + +body::after { + background: + radial-gradient(ellipse 70% 55% at 70% 70%, var(--smoke-2, rgba(255,255,255,0.22)) 0%, transparent 45%), + radial-gradient(ellipse 55% 45% at 30% 30%, var(--smoke-3, rgba(255,255,255,0.18)) 0%, transparent 50%), + radial-gradient(ellipse 45% 70% at 60% 40%, var(--smoke-1, rgba(255,255,255,0.15)) 0%, transparent 40%); + animation: smoke2 15s ease-in-out infinite; + filter: blur(60px); +} + +@keyframes smoke1 { + 0%, 100% { transform: translate(0, 0) scale(1); opacity: 0.9; } + 25% { transform: translate(8%, -5%) scale(1.1); opacity: 1; } + 50% { transform: translate(-5%, 8%) scale(0.9); opacity: 0.7; } + 75% { transform: translate(6%, 3%) scale(1.05); opacity: 1; } +} + +@keyframes smoke2 { + 0%, 100% { transform: translate(0, 0) scale(1) rotate(0deg); opacity: 0.8; } + 33% { transform: translate(-6%, 6%) scale(1.08) rotate(3deg); opacity: 1; } + 66% { transform: translate(5%, -4%) scale(0.92) rotate(-2deg); opacity: 0.85; } +} + +@media (prefers-reduced-motion: reduce) { + body::before, + body::after { + animation: none; + } +} #app { height: 100%; @@ -95,7 +151,8 @@ body { .enter-content { position: fixed; - bottom: 30vh; + bottom: 20vh; + z-index: 11; left: 50%; transform: translateX(-50%); text-align: center; @@ -175,6 +232,7 @@ body { display: none; } + /* Voice login container */ .password-container { display: flex; @@ -363,6 +421,48 @@ body { display: block; } +/* Cash Rain overlay — Konami reward */ +.cash-rain { + position: fixed; + inset: 0; + z-index: 9999; + pointer-events: none; + overflow: hidden; + animation: cash-fade 4s ease-out forwards; + perspective: 1000px; +} + +.cash-rain .bill { + position: absolute; + top: -120px; + width: var(--bill-width, 60px); + height: var(--bill-height, 25px); + background-image: var(--bill-img); + background-size: cover; + background-position: center; + transform-style: preserve-3d; + animation: fall-tumble var(--fall-duration, 2s) linear forwards; + animation-delay: var(--fall-delay, 0s); + opacity: var(--bill-opacity, 1); + box-shadow: 0 2px 4px rgba(0,0,0,0.3); +} + +@keyframes fall-tumble { + 0% { + transform: translateY(0) rotateZ(var(--start-rot, 0deg)) rotateX(0deg) rotateY(0deg); + opacity: var(--bill-opacity, 1); + } + 100% { + transform: translateY(calc(100vh + 150px)) rotateZ(var(--spin-z, 360deg)) rotateX(var(--spin-x, 720deg)) rotateY(var(--spin-y, 540deg)); + opacity: calc(var(--bill-opacity, 1) * 0.7); + } +} + +@keyframes cash-fade { + 0%, 60% { opacity: 1; } + 100% { opacity: 0; } +} + /* Player Screen */ .player-title { position: absolute; @@ -404,6 +504,9 @@ body { height: var(--artwork-size); border: 2px solid var(--muted); flex-shrink: 0; + position: relative; + overflow: hidden; + cursor: default; } .artwork-image { @@ -414,8 +517,21 @@ body { cursor: default; } -.artwork-container.no-art { +.artwork-canvas { + position: absolute; + inset: 0; + width: 100%; + height: 100%; display: none; + pointer-events: none; +} + +.artwork-container.no-art .artwork-image { + display: none; +} + +.artwork-container.no-art .artwork-canvas { + display: block; } /* Right side content wrapper - flows naturally on mobile */ @@ -489,15 +605,14 @@ body { } /* Artwork long-press hold feedback */ -.artwork-image.holding { +.artwork-container.holding { opacity: 0.7; - transition: opacity 0.3s; + transition: opacity 0.15s ease; } /* Clickable metadata (super/secret modes) */ -.artwork-image.clickable { +.artwork-container.clickable { cursor: pointer; - pointer-events: auto; } #artist, #album, #title, #year { @@ -1605,6 +1720,7 @@ body { .enter-content { position: fixed; bottom: 20vh; + z-index: 11; left: 50%; transform: translateX(-50%); width: auto; @@ -1900,3 +2016,595 @@ body { height: clamp(18px, 5vh, 24px) !important; } } + +/* ============================================ + State Sync UI + ============================================ */ + +/* Sync button — two rotating arrows */ +.state-sync-btn { + position: relative; +} + +.state-sync-btn svg { + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.state-sync-btn:hover svg { + transform: rotate(30deg); +} + +.state-sync-btn:active svg { + transform: rotate(180deg); + transition-duration: 0.15s; +} + +/* Syncing state — continuous rotation */ +.state-sync-btn.syncing svg { + animation: sync-spin 0.8s cubic-bezier(0.4, 0, 0.2, 1) infinite; +} + +/* Connected indicator — green dot */ +.state-sync-btn.connected::after { + content: ''; + position: absolute; + bottom: 2px; + right: 2px; + width: 6px; + height: 6px; + background: #4a4; + border: 1px solid var(--bg); +} + +/* Success flash */ +.state-sync-btn.sync-success { + color: #4a4 !important; + border-color: #4a4 !important; + animation: sync-success-flash 0.4s cubic-bezier(0.2, 0.8, 0.2, 1); +} + +.state-sync-btn.sync-error { + color: var(--accent) !important; + border-color: var(--accent) !important; +} + +@keyframes sync-spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +@keyframes sync-success-flash { + 0% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.7; transform: scale(1.15); } + 100% { opacity: 1; transform: scale(1); } +} + +/* Force sync button on favorites page — reuse state-sync-btn with a label */ +.sync-force-bar { + display: flex; + align-items: center; + justify-content: center; + padding: 8px 0; + margin-bottom: 10px; + opacity: 0; + transform: translateY(-10px); + animation: sync-force-bar-in 0.3s ease-out 0.2s both; +} + +@keyframes sync-force-bar-in { + to { + opacity: 1; + transform: translateY(0); + } +} + +.sync-force-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 14px; + background: transparent; + border: 1px solid var(--muted); + color: var(--muted); + font-family: 'Special Elite', cursive; + font-size: 11px; + letter-spacing: 0.05em; + cursor: pointer; + transition: all 0.2s ease-out; +} + +.sync-force-btn:hover { + border-color: var(--fg); + color: var(--fg); +} + +.sync-force-btn:active { + transform: scale(0.97); +} + +.sync-force-btn svg { + width: 12px; + height: 12px; + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.sync-force-btn.syncing svg { + animation: sync-spin 0.8s cubic-bezier(0.4, 0, 0.2, 1) infinite; +} + +/* ============================================ + Profile Screen + ============================================ */ +#profile-screen { + flex-direction: column; + align-items: center; + overflow-y: auto; + -webkit-overflow-scrolling: touch; +} + +.profile-container { + width: 100%; + max-width: 400px; + padding: 60px 24px 40px; + margin: 0 auto; +} + +.profile-back-btn { + position: fixed; + top: 16px; + left: 16px; + z-index: 20; + background: transparent; + border: none; + color: var(--fg); + cursor: pointer; + padding: 8px; + transition: opacity 0.2s ease; +} + +.profile-back-btn svg { + width: 24px; + height: 24px; +} + +.profile-back-btn:hover { + opacity: 0.7; +} + +.profile-username { + font-family: var(--font); + font-size: clamp(36px, 8vw, 56px); + font-weight: 700; + letter-spacing: 0.05em; + color: var(--fg); + margin: 0 0 32px 0; + text-transform: uppercase; +} + +.profile-stats { + margin: 0 0 32px 0; +} + +.stat-row { + display: flex; + justify-content: space-between; + align-items: baseline; + padding: 12px 0; + border-bottom: 1px solid var(--muted); +} + +.stat-row:first-child { + border-top: 1px solid var(--muted); +} + +.stat-label { + font-family: var(--font); + font-size: 14px; + letter-spacing: 0.15em; + color: var(--muted); +} + +.stat-value { + font-family: 'Courier New', monospace; + font-size: 16px; + color: var(--fg); + letter-spacing: 0.05em; +} + +.profile-sync { + margin: 0 0 32px 0; +} + +.sync-status-row { + display: flex; + align-items: center; + gap: 10px; + margin: 0 0 8px 0; +} + +.sync-status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--muted); + flex-shrink: 0; +} + +.sync-status-dot.connected { + background: #4ade80; +} + +.sync-status-dot.error { + background: #f87171; +} + +.sync-status-dot.syncing { + background: #facc15; + animation: pulse-dot 1s ease-in-out infinite; +} + +@keyframes pulse-dot { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +.sync-status-text { + font-family: var(--font); + font-size: 14px; + letter-spacing: 0.1em; + color: var(--muted); +} + +.sync-detail { + font-family: 'Courier New', monospace; + font-size: 12px; + color: var(--muted); + margin: 0 0 16px 0; + line-height: 1.5; +} + +.profile-sync-actions { + display: flex; + gap: 12px; +} + +.profile-action-btn { + background: transparent; + border: 2px solid var(--fg); + color: var(--fg); + font-family: var(--font); + font-size: 14px; + letter-spacing: 0.1em; + padding: 10px 20px; + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease, transform 0.1s ease; + flex: 1; +} + +.profile-action-btn:hover { + background: var(--fg); + color: var(--bg); +} + +.profile-action-btn:active { + transform: scale(0.97); +} + +.profile-action-btn.danger { + border-color: #f87171; + color: #f87171; +} + +.profile-action-btn.danger:hover { + background: #f87171; + color: var(--bg); +} + +.profile-action-btn.syncing { + opacity: 0.6; + pointer-events: none; +} + +/* Circle progress marks */ +.circle-progress { + margin: 0 0 32px 0; +} + +.circle-marks { + display: flex; + gap: 8px; + justify-content: center; +} + +.circle-mark { + width: 12px; + height: 12px; + border-radius: 50%; + border: 2px solid var(--muted); + background: transparent; + cursor: default; + transition: background 0.3s ease, border-color 0.3s ease, transform 0.15s ease; +} + +.circle-mark.unlocked { + border-color: var(--fg); + cursor: pointer; +} + +.circle-mark.unlocked:hover { + transform: scale(1.2); +} + +.circle-mark.current { + background: var(--fg); + border-color: var(--fg); +} + +/* Circle advancement pulse on profile nav button */ +.circle-advance { + animation: circle-pulse 2s ease-out forwards; +} + +@keyframes circle-pulse { + 0% { transform: scale(1); } + 15% { transform: scale(1.3); filter: brightness(1.5); } + 30% { transform: scale(1); } + 45% { transform: scale(1.15); filter: brightness(1.3); } + 60% { transform: scale(1); filter: brightness(1); } + 100% { transform: scale(1); } +} + +/* Credential form for unconnected users */ +.sync-heading { + font-family: var(--font); + font-size: 14px; + letter-spacing: 0.15em; + color: var(--muted); + margin: 0 0 16px 0; +} + +.profile-input { + display: block; + width: 100%; + background: transparent; + border: none; + border-bottom: 2px solid var(--muted); + color: var(--fg); + font-family: var(--font); + font-size: 16px; + letter-spacing: 0.05em; + padding: 12px 0; + margin: 0 0 16px 0; + outline: none; + transition: border-color 0.2s ease; +} + +.profile-input::placeholder { + color: var(--muted); + opacity: 0.6; +} + +.profile-input:focus { + border-bottom-color: var(--fg); +} + +.profile-creds .profile-action-btn { + width: 100%; + margin-top: 8px; +} + +/* Sync credentials modal */ +.sync-modal-content { + max-width: 400px; + padding: 32px; + text-align: center; +} + +.sync-modal-title { + font-family: var(--title-font); + font-size: 28px; + letter-spacing: 0.05em; + color: var(--fg); + margin: 0 0 8px 0; +} + +.sync-modal-subtitle { + font-family: var(--font); + font-size: 14px; + color: var(--muted); + margin: 0 0 24px 0; + letter-spacing: 0.05em; +} + +.sync-modal-form { + text-align: left; +} + +.sync-input-group { + position: relative; + margin-bottom: 16px; +} + +.sync-modal-input { + display: block; + width: 100%; + background: transparent; + border: none; + border-bottom: 2px solid var(--muted); + color: var(--fg); + font-family: var(--font); + font-size: 16px; + letter-spacing: 0.05em; + padding: 12px 40px 12px 0; + outline: none; + transition: border-color 0.2s ease; + box-sizing: border-box; +} + +.sync-modal-input::placeholder { + color: var(--muted); + opacity: 0.6; +} + +.sync-modal-input:focus { + border-bottom-color: var(--fg); +} + +.sync-modal-input.invalid { + border-bottom-color: #f87171; +} + +.sync-input-count { + position: absolute; + right: 0; + bottom: 14px; + font-family: 'Courier New', monospace; + font-size: 11px; + color: var(--muted); + opacity: 0; + transition: opacity 0.2s ease; + pointer-events: none; +} + +.sync-modal-input:focus ~ .sync-input-count { + opacity: 1; +} + +.sync-input-count.at-limit { + color: #f87171; + opacity: 1; +} + +.sync-modal-error { + font-family: var(--font); + font-size: 13px; + color: #f87171; + margin: 0 0 12px 0; + letter-spacing: 0.05em; + text-align: center; +} + +.sync-modal-error.hidden { + display: none; +} + +.sync-modal-error.success { + color: #4ade80; +} + +.sync-modal-submit { + width: 100%; + margin-top: 8px; +} + +/* DEBUG STYLES — REMOVE BEFORE RELEASE */ +.profile-debug { + margin-top: 40px; + border: 1px dashed var(--muted); + padding: 16px; + opacity: 0.7; +} + +.debug-heading { + font-family: var(--font); + font-size: 12px; + letter-spacing: 0.2em; + color: var(--muted); + margin: 0 0 12px 0; +} + +.debug-output { + font-family: 'Courier New', monospace; + font-size: 11px; + line-height: 1.6; + color: var(--muted); + white-space: pre-wrap; + word-break: break-all; + margin: 0; + max-height: 300px; + overflow-y: auto; +} +/* END DEBUG STYLES */ + +.sync-force-btn.sync-success { + border-color: #4a4; + color: #4a4; +} + +/* ============================================ + Admin Debug Strip + ============================================ */ +.debug-strip { + position: fixed; + bottom: 0; + left: 0; + right: 0; + z-index: 10000; + background: rgba(0, 255, 0, 0.1); + border-top: 1px solid rgba(0, 255, 0, 0.3); + padding: 4px 12px; + font-family: 'Courier New', monospace; + font-size: 10px; + color: #0f0; + cursor: pointer; + user-select: none; + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); +} + +.debug-strip-info { + display: flex; + gap: 4px; + justify-content: center; + align-items: center; +} + +.debug-sep { + opacity: 0.4; +} + +.debug-menu { + position: fixed; + bottom: 24px; + left: 0; + right: 0; + z-index: 9999; + background: rgba(0, 0, 0, 0.95); + border-top: 1px solid rgba(0, 255, 0, 0.3); + padding: 12px; + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); +} + +.debug-menu-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); + gap: 6px; + max-width: 500px; + margin: 0 auto; +} + +.debug-action { + background: transparent; + border: 1px solid rgba(0, 255, 0, 0.4); + color: #0f0; + font-family: 'Courier New', monospace; + font-size: 10px; + letter-spacing: 0.05em; + padding: 8px 4px; + cursor: pointer; + transition: background 0.15s ease; +} + +.debug-action:hover { + background: rgba(0, 255, 0, 0.15); +} + +.debug-action:active { + background: rgba(0, 255, 0, 0.3); +} + +/* Shift mini-player up when debug strip is visible */ +.debug-strip:not(.hidden) ~ .mini-player { + bottom: 24px; +} diff --git a/www/sw.js b/www/sw.js index 6020c7a..d752f89 100644 --- a/www/sw.js +++ b/www/sw.js @@ -4,7 +4,7 @@ * artwork (cache-first), audio (cache-first + cache-on-play) */ -const SHELL_CACHE = 'shell-v1'; +const SHELL_CACHE = 'shell-v4'; const MANIFEST_CACHE = 'manifest-v1'; const ARTWORK_CACHE = 'artwork-v1'; const AUDIO_CACHE = 'audio-v1'; @@ -25,17 +25,23 @@ const SHELL_ASSETS = [ '/js/cookies.js', '/js/elements.js', '/js/events.js', + '/js/genart.js', '/js/hash.js', '/js/konami.js', '/js/player.js', '/js/pwa.js', '/js/state.js', '/js/storage.js', + '/js/sync.js', + '/js/crypto.js', + '/js/circles.js', '/js/tracks.js', '/js/ui.js', '/js/utils.js', '/js/version.js', '/js/voice.js', + '/img/benj_front.jpeg', + '/img/benj_back.jpeg', '/favicon.svg', '/icons/icon-192.png', '/icons/icon-512.png', @@ -46,6 +52,7 @@ const SHELL_ASSETS = [ function isPassthrough(url) { const path = new URL(url).pathname; if (path === '/version.txt') return true; + if (path.startsWith('/sync/')) return true; // Cross-origin (GA, fonts) handled by origin check below return false; } @@ -58,7 +65,8 @@ function isShellRequest(url) { p.endsWith('.css') || (p.endsWith('.js') && !p.startsWith('/audio/')) || p.startsWith('/icons/') || - p.startsWith('/favicon'); + p.startsWith('/favicon') || + p.startsWith('/img/'); } function isManifestRequest(url) {