document.addEventListener('DOMContentLoaded', async () => { // --- AUDIO DATA (INLINED BY BUILD SCRIPT) --- // __SOUND_DATA_PLACEHOLDER__ const app = { // --- STATE --- workouts: [], db: null, currentView: 'home', currentWorkoutId: null, currentExerciseIndex: 0, runnerInterval: null, wakeLock: null, userHasInteracted: false, timerState: 'idle', timeRemainingOnPause: 0, currentRepOnPause: 1, // Web Audio API state audioContext: null, soundBuffers: {}, soundsLoaded: false, audioLoading: false, runnerViewEl: null, // --- INITIALIZATION & CORE --- async init() { await this.initDB(); await this.loadWorkouts(); this.attachEventListeners(); this.registerServiceWorker(); this.render(); }, registerServiceWorker() { if ('serviceWorker' in navigator) { navigator.serviceWorker.register('/service-worker.js'); } }, async initDB() { this.db = await idb.openDB('WorkItDB', 1, { upgrade(db) { db.createObjectStore('workouts', { keyPath: 'id' }); }, }); console.log("Database initialized."); }, async loadWorkouts() { const oldData = localStorage.getItem('workouts'); if (oldData) { console.log("Found old data in localStorage. Migrating to IndexedDB..."); try { const oldWorkouts = JSON.parse(oldData); if (Array.isArray(oldWorkouts)) { const tx = this.db.transaction('workouts', 'readwrite'); await Promise.all(oldWorkouts.map(workout => tx.store.put(workout))); await tx.done; localStorage.removeItem('workouts'); console.log("Migration successful."); } } catch (e) { console.error("Failed to parse or migrate old data:", e); } } this.workouts = await this.db.getAll('workouts'); }, async saveWorkouts() { const tx = this.db.transaction('workouts', 'readwrite'); await tx.store.clear(); await Promise.all(this.workouts.map(workout => tx.store.put(workout))); await tx.done; }, async acquireWakeLock() { if ('wakeLock' in navigator) try { this.wakeLock = await navigator.wakeLock.request('screen'); } catch (e) {} }, async releaseWakeLock() { if (this.wakeLock) { await this.wakeLock.release(); this.wakeLock = null; } }, async initAudio(triggerElement) { if (this.audioLoading || this.soundsLoaded) return; this.audioLoading = true; if (triggerElement) { triggerElement.disabled = true; triggerElement.textContent = 'Loading...'; } if (!this.audioContext) this.audioContext = new (window.AudioContext || window.webkitAudioContext)(); await this.loadSounds(); this.audioLoading = false; if (triggerElement) { triggerElement.disabled = false; if (triggerElement.dataset.action === 'start') { triggerElement.innerHTML = '▶'; } else { triggerElement.textContent = 'Start Exercise'; } } }, async loadSounds() { if (typeof soundData === 'undefined') { console.error("Sound data not defined."); return; } const soundNames = Object.keys(soundData); const loadPromises = soundNames.map(async (name) => { try { const response = await fetch(soundData[name]); const arrayBuffer = await response.arrayBuffer(); const audioBuffer = await this.audioContext.decodeAudioData(arrayBuffer); this.soundBuffers[name] = audioBuffer; } catch (e) { console.error(`Failed to decode sound: ${name}`, e); } }); await Promise.all(loadPromises); this.soundsLoaded = true; console.log("All inlined sounds decoded."); }, playSound(name) { if (!this.audioContext || !this.soundBuffers[name]) return; if (this.audioContext.state === 'suspended') this.audioContext.resume(); const source = this.audioContext.createBufferSource(); source.buffer = this.soundBuffers[name]; source.connect(this.audioContext.destination); source.start(0); }, render() { const container = document.getElementById('app-container'); container.innerHTML = ''; if (this.runnerViewEl) { this.runnerViewEl.remove(); this.runnerViewEl = null; } if (this.currentView === 'home') this.renderHome(container); if (this.currentView === 'editor') this.renderEditor(container); }, renderHome(container) { let content = `

My Workouts

`; if (Array.isArray(this.workouts)) { this.workouts.forEach(workout => { content += `

${workout.name}

`; }); } content += ``; container.innerHTML = content; }, renderEditor(container) { const workout = this.workouts.find(w => w.id === this.currentWorkoutId) || { id: Date.now(), name: 'New Workout', exercises: [] }; let exercisesHtml = ''; workout.exercises.forEach((ex, index) => { const showDelay = ex.reps && ex.time; exercisesHtml += `

${ex.name || 'New Exercise'}

${ex.description || ''}

Reps: ${ex.reps || 'N/A'} | Time: ${ex.time || 'N/A'}s | Delay: ${ex.delay || 'N/A'}s

`; }); const content = `

Exercises

${exercisesHtml}
`; container.innerHTML = content; }, attachEventListeners() { document.body.addEventListener('click', async (e) => { const target = e.target.closest('[data-action]'); if (!target) return; if (this.audioContext && this.audioContext.state === 'suspended') await this.audioContext.resume(); const action = target.dataset.action; if (!this.soundsLoaded && (action === 'start' || action === 'get-ready')) await this.initAudio(target); const workoutEl = target.closest('.workout-list-item'); const workoutId = workoutEl ? parseInt(workoutEl.dataset.id) : this.currentWorkoutId; const actions = { 'new-workout': () => this.newWorkout(), 'edit': () => this.editWorkout(workoutId), 'delete': () => this.deleteWorkout(workoutId), 'clone': () => this.cloneWorkout(workoutId), 'start': () => this.startRunner(workoutId), 'save-workout': () => this.saveWorkoutFromEditor(), 'back-home': () => this.goHome(), 'add-exercise': () => this.addExercise(), 'delete-exercise': () => this.deleteExercise(parseInt(target.closest('.exercise-card').dataset.index)), 'move-up': () => this.moveExercise(parseInt(target.closest('.exercise-card').dataset.index), -1), 'move-down': () => this.moveExercise(parseInt(target.closest('.exercise-card').dataset.index), 1), 'stop-runner': () => this.stopRunner(), 'next-exercise': () => this.changeExercise(1), 'prev-exercise': () => this.changeExercise(-1), 'get-ready': () => this.runGetReadyCountdown(), 'pause-timer': () => this.pauseTimer(), 'resume-timer': () => this.resumeTimer(), 'reset-timer': () => this.resetTimer(), 'clone-workout': () => this.cloneWorkout(workoutId), 'delete-workout': () => this.deleteWorkout(workoutId), 'finish-workout': () => this.renderCompletionScreen(), }; if (actions[action]) await actions[action](); }); document.body.addEventListener('input', e => { if (this.currentView !== 'editor') return; const field = e.target.dataset.field; if (!field) return; const exerciseCard = e.target.closest('.exercise-card'); const exerciseIndex = exerciseCard ? parseInt(exerciseCard.dataset.index) : null; const workout = this.workouts.find(w => w.id === this.currentWorkoutId); if (workout && exerciseIndex !== null) { const value = e.target.value; const exercise = workout.exercises[exerciseIndex]; exercise[field] = e.target.type === 'number' ? (value ? parseInt(value) : null) : value; if (field === 'reps' || field === 'time') { const delayGroup = exerciseCard.querySelector('.delay-group'); const showDelay = exercise.reps && exercise.time; delayGroup.style.display = showDelay ? 'block' : 'none'; } } }); }, goHome() { this.currentView = 'home'; this.currentWorkoutId = null; this.render(); }, async newWorkout() { const w = { id: Date.now(), name: 'New Workout', exercises: [] }; this.workouts.push(w); await this.saveWorkouts(); this.currentWorkoutId = w.id; this.currentView = 'editor'; this.render(); }, editWorkout(id) { this.currentWorkoutId = id; this.currentView = 'editor'; this.render(); }, async deleteWorkout(id) { if (confirm('Permanently delete this workout?')) { this.workouts = this.workouts.filter(w => w.id !== id); await this.saveWorkouts(); this.goHome(); } }, async cloneWorkout(id) { const o = this.workouts.find(w => w.id === id); if (o) { const c = JSON.parse(JSON.stringify(o)); c.id = Date.now(); c.name = `${o.name} (Copy)`; this.workouts.push(c); await this.saveWorkouts(); this.editWorkout(c.id); } }, addExercise() { const w = this.workouts.find(w => w.id === this.currentWorkoutId); if (w) { w.exercises.push({ name: 'New Exercise', reps: null, time: null, delay: null }); this.render(); } }, deleteExercise(i) { const w = this.workouts.find(w => w.id === this.currentWorkoutId); if (w) { w.exercises.splice(i, 1); this.render(); } }, moveExercise(i, dir) { const w = this.workouts.find(w => w.id === this.currentWorkoutId); if (!w) return; const newI = i + dir; if (newI < 0 || newI >= w.exercises.length) return; const [item] = w.exercises.splice(i, 1); w.exercises.splice(newI, 0, item); this.render(); }, async saveWorkoutFromEditor() { const workout = this.workouts.find(w => w.id === this.currentWorkoutId); if (workout) { workout.name = document.getElementById('workout-name').value; } await this.saveWorkouts(); this.goHome(); }, startRunner(id) { this.currentWorkoutId = id; this.currentView = 'runner'; this.currentExerciseIndex = 0; this.acquireWakeLock(); if (!this.runnerViewEl) { this.runnerViewEl = document.createElement('div'); this.runnerViewEl.className = 'runner-view'; this.runnerViewEl.innerHTML = `

`; document.body.appendChild(this.runnerViewEl); } this.updateRunnerView(); }, stopRunner() { clearInterval(this.runnerInterval); this.releaseWakeLock(); this.goHome(); }, initiateExercise() { clearInterval(this.runnerInterval); this.timerState = 'idle'; this.updateRunnerView(); }, runGetReadyCountdown() { const exercise = this.workouts.find(w => w.id === this.currentWorkoutId).exercises[this.currentExerciseIndex]; this.runnerViewEl.querySelector('.runner-exercise-name').textContent = `Get Ready: ${exercise.name}`; this.runnerViewEl.querySelector('.runner-description').textContent = exercise.description || ''; this.runnerViewEl.querySelector('#runner-action-button').style.display = 'none'; this.runnerViewEl.querySelector('#timer-controls').style.display = 'none'; const timerDisplay = this.runnerViewEl.querySelector('#runner-timer-display'); let countdown = 3; timerDisplay.textContent = countdown; this.playSound('tick'); this.runnerInterval = setInterval(() => { countdown--; if (countdown > 0) { timerDisplay.textContent = countdown; this.playSound('tick'); } else { clearInterval(this.runnerInterval); this.startCurrentExerciseTimer(true); } }, 1000); }, updateRunnerView() { const workout = this.workouts.find(w => w.id === this.currentWorkoutId); if (!workout) { this.stopRunner(); return; } const exercise = workout.exercises[this.currentExerciseIndex]; const isLastExercise = this.currentExerciseIndex === workout.exercises.length - 1; // Update progress pips this.updateProgressPips(workout.exercises.length, this.currentExerciseIndex); this.runnerViewEl.querySelector('.runner-exercise-name').textContent = exercise.name; this.runnerViewEl.querySelector('.runner-description').textContent = exercise.description || ''; const actionButton = this.runnerViewEl.querySelector('#runner-action-button'); const repsDisplay = this.runnerViewEl.querySelector('#runner-reps-display'); const timerDisplay = this.runnerViewEl.querySelector('#runner-timer-display'); timerDisplay.textContent = ''; repsDisplay.textContent = ''; this.runnerViewEl.querySelector('#timer-controls').style.display = 'none'; actionButton.style.display = 'block'; if (exercise.reps && !exercise.time) { repsDisplay.textContent = `${exercise.reps} Reps`; actionButton.textContent = 'Complete'; actionButton.dataset.action = isLastExercise ? 'finish-workout' : 'next-exercise'; } else if (exercise.time) { repsDisplay.textContent = exercise.reps ? `Set: ${exercise.reps} x ${exercise.time}s` : `Set: ${exercise.time}s`; actionButton.textContent = 'Start Exercise'; actionButton.dataset.action = 'get-ready'; } else { repsDisplay.textContent = ''; actionButton.textContent = 'Complete'; actionButton.dataset.action = isLastExercise ? 'finish-workout' : 'next-exercise'; } const prevButton = this.runnerViewEl.querySelector('[data-action="prev-exercise"]'); const nextButton = this.runnerViewEl.querySelector('[data-action="next-exercise"]'); prevButton.disabled = this.currentExerciseIndex === 0; nextButton.disabled = isLastExercise; }, updateProgressPips(totalExercises, currentExercise) { const progressContainer = this.runnerViewEl.querySelector('.runner-progress'); progressContainer.innerHTML = ''; for (let i = 0; i < totalExercises; i++) { const pip = document.createElement('div'); pip.className = `runner-pip ${i === currentExercise ? 'active' : ''}`; progressContainer.appendChild(pip); } }, startCurrentExerciseTimer(isFirstRep = false) { this.timerState = 'running'; const exercise = this.workouts.find(w => w.id === this.currentWorkoutId).exercises[this.currentExerciseIndex]; this.runnerViewEl.querySelector('.runner-exercise-name').textContent = exercise.name; if (isFirstRep) this.playSound('start'); this.runnerViewEl.querySelector('#runner-action-button').style.display = 'none'; this.runnerViewEl.querySelector('#timer-controls').style.display = 'flex'; const pauseBtn = this.runnerViewEl.querySelector('#pause-resume-button'); pauseBtn.textContent = 'Pause'; pauseBtn.dataset.action = 'pause-timer'; if (exercise.reps) { this.runRepTimer(exercise, 1); } else { this.runSingleTimer(exercise.time); } }, changeExercise(direction) { const workout = this.workouts.find(w => w.id === this.currentWorkoutId); if (!workout) { this.stopRunner(); return; } const newIndex = this.currentExerciseIndex + direction; if (newIndex >= 0 && newIndex < workout.exercises.length) { this.currentExerciseIndex = newIndex; this.initiateExercise(); } }, completeExercise() { this.playSound('exercise-complete'); this.timerState = 'idle'; clearInterval(this.runnerInterval); const workout = this.workouts.find(w => w.id === this.currentWorkoutId); const isLastExercise = this.currentExerciseIndex === workout.exercises.length - 1; if (isLastExercise) { this.renderCompletionScreen(); } else { this.runnerViewEl.querySelector('#timer-controls').style.display = 'none'; const actionButton = this.runnerViewEl.querySelector('#runner-action-button'); actionButton.style.display = 'block'; actionButton.textContent = 'Next Exercise'; actionButton.dataset.action = 'next-exercise'; } }, renderCompletionScreen() { this.playSound('workout-complete'); const contentEl = this.runnerViewEl.querySelector('.runner-content'); this.runnerViewEl.querySelector('.runner-nav').style.display = 'none'; contentEl.innerHTML = `

Workout Complete!

Great job!

`; }, runSingleTimer(duration) { let timeLeft = duration; const timerDisplay = this.runnerViewEl.querySelector('#runner-timer-display'); timerDisplay.textContent = timeLeft; this.runnerInterval = setInterval(() => { timeLeft--; timerDisplay.textContent = timeLeft; if (timeLeft > 0 && timeLeft <= 5) this.playSound('countdown'); if (timeLeft <= 0) { this.completeExercise(); } }, 1000); }, runRepTimer(exercise, currentRep, startTime) { this.timerState = 'running'; const { reps, time, delay } = exercise; if (currentRep > 1 && !startTime) this.playSound('rep-start'); const repsDisplay = this.runnerViewEl.querySelector('#runner-reps-display'); repsDisplay.textContent = `Rep ${currentRep} / ${reps}`; const timerDisplay = this.runnerViewEl.querySelector('#runner-timer-display'); let timeLeft = startTime || time; timerDisplay.textContent = timeLeft; this.runnerInterval = setInterval(() => { timeLeft--; timerDisplay.textContent = timeLeft; if (timeLeft > 0 && timeLeft <= 5) this.playSound('countdown'); if (timeLeft <= 0) { clearInterval(this.runnerInterval); if (currentRep < reps) { this.playSound('rep-complete'); setTimeout(() => this.runDelayTimer(exercise, currentRep), 250); } else { this.completeExercise(); } } }, 1000); }, runDelayTimer(exercise, currentRep) { this.runnerViewEl.querySelector('#runner-reps-display').textContent = `Rest`; const timerDisplay = this.runnerViewEl.querySelector('#runner-timer-display'); let delayLeft = exercise.delay; timerDisplay.textContent = delayLeft; this.runnerInterval = setInterval(() => { delayLeft--; timerDisplay.textContent = delayLeft; if (delayLeft > 0) this.playSound('tick'); if (delayLeft <= 0) { clearInterval(this.runnerInterval); this.runRepTimer(exercise, currentRep + 1); } }, 1000); }, pauseTimer() { if (this.timerState !== 'running') return; clearInterval(this.runnerInterval); this.timerState = 'paused'; this.timeRemainingOnPause = parseInt(this.runnerViewEl.querySelector('#runner-timer-display').textContent); const repsDisplay = this.runnerViewEl.querySelector('#runner-reps-display').textContent; if (repsDisplay.includes('Rep')) { this.currentRepOnPause = parseInt(repsDisplay.split(' ')[1]); } const pauseResumeBtn = this.runnerViewEl.querySelector('#pause-resume-button'); pauseResumeBtn.textContent = 'Resume'; pauseResumeBtn.dataset.action = 'resume-timer'; }, resumeTimer() { if (this.timerState !== 'paused') return; this.timerState = 'running'; const exercise = this.workouts.find(w => w.id === this.currentWorkoutId).exercises[this.currentExerciseIndex]; const pauseResumeBtn = this.runnerViewEl.querySelector('#pause-resume-button'); pauseResumeBtn.textContent = 'Pause'; pauseResumeBtn.dataset.action = 'pause-timer'; if (exercise.reps) { this.runRepTimer(exercise, this.currentRepOnPause, this.timeRemainingOnPause); } else { this.runSingleTimer(this.timeRemainingOnPause); } }, resetTimer() { clearInterval(this.runnerInterval); this.timerState = 'idle'; this.updateRunnerView(); } }; await app.init(); });