543 lines
27 KiB
JavaScript
543 lines
27 KiB
JavaScript
|
|
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 = `<h1>My Workouts</h1>`;
|
||
|
|
if (Array.isArray(this.workouts)) {
|
||
|
|
this.workouts.forEach(workout => {
|
||
|
|
content += `
|
||
|
|
<div class="workout-list-item" data-id="${workout.id}">
|
||
|
|
<h3>${workout.name}</h3>
|
||
|
|
<div class="workout-actions">
|
||
|
|
<button data-action="edit" title="Edit">Edit</button>
|
||
|
|
<button data-action="start" title="Start Workout">▶</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
`;
|
||
|
|
});
|
||
|
|
}
|
||
|
|
content += `<button class="button" data-action="new-workout">Create New Workout</button>`;
|
||
|
|
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 += `
|
||
|
|
<div class="exercise-card" data-index="${index}">
|
||
|
|
<div class="exercise-header">
|
||
|
|
<h4>${ex.name || 'New Exercise'}</h4>
|
||
|
|
<div class="exercise-controls">
|
||
|
|
<button data-action="move-up" title="Move Up">↑</button>
|
||
|
|
<button data-action="move-down" title="Move Down">↓</button>
|
||
|
|
<button data-action="delete-exercise" class="delete" title="Delete Exercise">×</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div class="exercise-details"> <p>${ex.description || ''}</p> <p><strong>Reps:</strong> ${ex.reps || 'N/A'} | <strong>Time:</strong> ${ex.time || 'N/A'}s | <strong>Delay:</strong> ${ex.delay || 'N/A'}s</p> </div>
|
||
|
|
<div class="input-group"> <label>Name</label> <input type="text" data-field="name" value="${ex.name || ''}"> </div>
|
||
|
|
<div class="input-group"> <label>Description</label> <textarea data-field="description">${ex.description || ''}</textarea> </div>
|
||
|
|
<div class="input-group"> <label>Reps</label> <input type="number" data-field="reps" value="${ex.reps || ''}"> </div>
|
||
|
|
<div class="input-group"> <label>Time (seconds)</label> <input type="number" data-field="time" value="${ex.time || ''}"> </div>
|
||
|
|
<div class="input-group delay-group" style="display: ${showDelay ? 'block' : 'none'};"> <label>Delay between reps (seconds)</label> <input type="number" data-field="delay" value="${ex.delay || ''}"> </div>
|
||
|
|
</div>
|
||
|
|
`;
|
||
|
|
});
|
||
|
|
const content = `
|
||
|
|
<div class="input-group"> <label for="workout-name">Workout Name</label> <input type="text" id="workout-name" value="${workout.name}"> </div>
|
||
|
|
<h2>Exercises</h2> <div id="exercise-list">${exercisesHtml}</div>
|
||
|
|
<button class="button secondary" data-action="add-exercise">Add Exercise</button>
|
||
|
|
<button class="button" data-action="save-workout">Save Workout</button>
|
||
|
|
<div class="editor-actions">
|
||
|
|
<button class="button secondary" data-action="clone-workout">Clone Workout</button>
|
||
|
|
<button class="button danger" data-action="delete-workout">Delete Workout</button>
|
||
|
|
</div>
|
||
|
|
<button class="button secondary" data-action="back-home" style="margin-top: 2rem;">Back to Home</button>
|
||
|
|
`;
|
||
|
|
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 = `
|
||
|
|
<button class="runner-close-button" data-action="stop-runner">End Workout</button>
|
||
|
|
<div class="runner-content">
|
||
|
|
<h2 class="runner-exercise-name"></h2><p class="runner-reps" id="runner-reps-display"></p><div class="runner-timer" id="runner-timer-display"></div>
|
||
|
|
<p class="runner-description"></p><button class="button" id="runner-action-button"></button>
|
||
|
|
<div class="timer-controls" id="timer-controls" style="display: none;">
|
||
|
|
<button class="button secondary" id="pause-resume-button"></button><button class="button danger" data-action="reset-timer">Reset</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div class="runner-progress"></div>
|
||
|
|
<div class="runner-nav"><div class="nav-button" data-action="prev-exercise">‹</div><div class="nav-button" data-action="next-exercise">›</div></div>`;
|
||
|
|
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 = `
|
||
|
|
<h2 class="runner-exercise-name">Workout Complete!</h2>
|
||
|
|
<p class="runner-description">Great job!</p>
|
||
|
|
<button class="button" data-action="stop-runner" style="margin-top: 3rem;">Back to Home</button>
|
||
|
|
`;
|
||
|
|
},
|
||
|
|
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();
|
||
|
|
});
|