Spaces:
Running
Running
| /** | |
| * Lo-fi Focus Timer — Main Script | |
| * ═══════════════════════════════════════════════════════════════════════════ | |
| * A minimal, elegant pomodoro timer with lo-fi vibes | |
| * Made with 💙 by Kai | |
| * ═══════════════════════════════════════════════════════════════════════════ | |
| */ | |
| (function () { | |
| "use strict"; | |
| // ═══════════════════════════════════════════════════════════════════════ | |
| // CONSTANTS | |
| // ═══════════════════════════════════════════════════════════════════════ | |
| // Mutable for custom durations | |
| let MODES = { | |
| focus: { time: 25, label: "Focus Time", color: "focus" }, | |
| short: { time: 5, label: "Short Break", color: "short" }, | |
| long: { time: 15, label: "Long Break", color: "long" } | |
| }; | |
| // Ambient sounds 🌙 | |
| const AMBIENT_SOUNDS = { | |
| rain: { name: "Rain", file: "sounds/rain.mp3" }, | |
| fire: { name: "Fire", file: "sounds/fire.mp3" }, | |
| cafe: { name: "Café", file: "sounds/cafe.mp3" }, | |
| forest: { name: "Forest", file: "sounds/forest.mp3" }, | |
| waves: { name: "Waves", file: "sounds/waves.mp3" }, | |
| thunder: { name: "Thunder", file: "sounds/thunder.mp3" } | |
| }; | |
| const RING_CIRCUMFERENCE = 565.48; // 2 * π * 90 | |
| const SESSIONS_BEFORE_LONG_BREAK = 4; | |
| // Radio stations 🎵 | |
| const RADIO_STATIONS = { | |
| // === Lo-Fi & Chill === | |
| "lofi-girl": { | |
| name: "☕ Lofi Girl", | |
| url: "https://play.streamafrica.net/lofiradio" | |
| }, | |
| chillhop: { | |
| name: "🎧 Chillhop", | |
| url: "https://streams.fluxfm.de/Chillhop/mp3-320" | |
| }, | |
| "jazz-lofi": { | |
| name: "🎷 Jazz Lo-Fi", | |
| url: "https://streams.fluxfm.de/jazzradio/mp3-320" | |
| }, | |
| // === FIP (Radio France) === | |
| "fip-groove": { | |
| name: "🎸 FIP Groove", | |
| url: "https://icecast.radiofrance.fr/fipgroove-midfi.mp3" | |
| }, | |
| "fip-jazz": { | |
| name: "🎺 FIP Jazz", | |
| url: "https://icecast.radiofrance.fr/fipjazz-midfi.mp3" | |
| }, | |
| "fip-electro": { | |
| name: "🎹 FIP Electro", | |
| url: "https://icecast.radiofrance.fr/fipelectro-midfi.mp3" | |
| }, | |
| "fip-world": { | |
| name: "🌍 FIP World", | |
| url: "https://icecast.radiofrance.fr/fipworld-midfi.mp3" | |
| }, | |
| "fip-pop": { | |
| name: "🎤 FIP Pop", | |
| url: "https://icecast.radiofrance.fr/fippop-midfi.mp3" | |
| }, | |
| // === Ambient & Focus === | |
| "soma-drone": { | |
| name: "🌌 SomaFM Drone", | |
| url: "https://ice1.somafm.com/dronezone-128-mp3" | |
| }, | |
| "soma-space": { | |
| name: "🚀 SomaFM Space", | |
| url: "https://ice1.somafm.com/spacestation-128-mp3" | |
| }, | |
| "soma-groove": { | |
| name: "🎶 SomaFM Groove", | |
| url: "https://ice1.somafm.com/groovesalad-128-mp3" | |
| } | |
| }; | |
| // ═══════════════════════════════════════════════════════════════════════ | |
| // STATE | |
| // ═══════════════════════════════════════════════════════════════════════ | |
| let state = { | |
| mode: "focus", | |
| timeRemaining: MODES.focus.time * 60, // in seconds | |
| totalTime: MODES.focus.time * 60, | |
| isRunning: false, | |
| sessionCount: 1, | |
| intervalId: null, | |
| settings: { | |
| soundEnabled: true, | |
| autoStartBreaks: false | |
| }, | |
| radio: { | |
| isPlaying: false, | |
| currentStation: "fip-groove", | |
| volume: 0.5 | |
| }, | |
| ambient: { | |
| active: [], // Multiple sounds can play | |
| volume: 0.3 | |
| }, | |
| customDurations: { | |
| focus: 25, | |
| short: 5, | |
| long: 15 | |
| }, | |
| notificationsEnabled: false | |
| }; | |
| // Audio element for radio | |
| let radioAudio = null; | |
| // Ambient audio elements (one per sound type) | |
| let ambientAudios = {}; | |
| // Single AudioContext for notifications (avoid memory leaks) | |
| let audioContext = null; | |
| // ═══════════════════════════════════════════════════════════════════════ | |
| // BROWSER NOTIFICATIONS 🔔 | |
| // ═══════════════════════════════════════════════════════════════════════ | |
| function requestNotificationPermission() { | |
| if (!("Notification" in window)) { | |
| console.log("🔔 Notifications not supported"); | |
| return; | |
| } | |
| if (Notification.permission === "granted") { | |
| state.notificationsEnabled = true; | |
| } else if (Notification.permission !== "denied") { | |
| Notification.requestPermission().then(permission => { | |
| state.notificationsEnabled = permission === "granted"; | |
| if (elements.notifToggle) { | |
| elements.notifToggle.checked = state.notificationsEnabled; | |
| } | |
| saveSettings(); | |
| }); | |
| } | |
| } | |
| function sendNotification(title, body) { | |
| if (!state.notificationsEnabled || Notification.permission !== "granted") return; | |
| try { | |
| const notif = new Notification(title, { | |
| body: body, | |
| icon: "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⚡</text></svg>", | |
| badge: "⚡", | |
| tag: "lofi-focus-timer", | |
| silent: true // We have our own sound | |
| }); | |
| // Auto-close after 5 seconds | |
| setTimeout(() => notif.close(), 5000); | |
| // Click to focus the window | |
| notif.onclick = () => { | |
| window.focus(); | |
| notif.close(); | |
| }; | |
| } catch (e) { | |
| console.log("🔔 Notification failed:", e); | |
| } | |
| } | |
| // ═══════════════════════════════════════════════════════════════════════ | |
| // AMBIENT SOUNDS 🌙 | |
| // ═══════════════════════════════════════════════════════════════════════ | |
| function initAmbient() { | |
| // Pre-create audio elements for each sound | |
| Object.keys(AMBIENT_SOUNDS).forEach(key => { | |
| const audio = new Audio(); | |
| audio.loop = true; | |
| audio.volume = state.ambient.volume; | |
| audio.preload = "none"; // Only load when needed | |
| ambientAudios[key] = audio; | |
| }); | |
| loadAmbientSettings(); | |
| } | |
| function toggleAmbientSound(soundKey) { | |
| const audio = ambientAudios[soundKey]; | |
| const btn = document.querySelector(`.ambient-btn[data-sound="${soundKey}"]`); | |
| if (!audio || !btn) return; | |
| if (state.ambient.active.includes(soundKey)) { | |
| // Stop this sound | |
| audio.pause(); | |
| audio.currentTime = 0; | |
| state.ambient.active = state.ambient.active.filter(s => s !== soundKey); | |
| btn.classList.remove("active"); | |
| console.log(`🌙 Stopped: ${AMBIENT_SOUNDS[soundKey].name}`); | |
| // Update visualizer connection | |
| updateVisualizerConnection(); | |
| } else { | |
| // Play this sound | |
| audio.src = AMBIENT_SOUNDS[soundKey].file; | |
| audio.volume = state.ambient.volume; | |
| audio | |
| .play() | |
| .then(() => { | |
| // Connect to visualizer if this is the first/only active sound | |
| updateVisualizerConnection(); | |
| }) | |
| .catch(e => { | |
| console.log(`🌙 Could not play ${soundKey}:`, e.message); | |
| btn.classList.add("error"); | |
| setTimeout(() => btn.classList.remove("error"), 2000); | |
| }); | |
| state.ambient.active.push(soundKey); | |
| btn.classList.add("active"); | |
| console.log(`🌙 Playing: ${AMBIENT_SOUNDS[soundKey].name}`); | |
| } | |
| saveAmbientSettings(); | |
| } | |
| // Connect the best audio source to visualizer 🎵⚡ | |
| function updateVisualizerConnection() { | |
| if (typeof window.connectAudioVisualizer !== "function") return; | |
| // Priority: Radio > First active ambient sound | |
| if (state.radio.isPlaying && radioAudio) { | |
| window.connectAudioVisualizer(radioAudio); | |
| return; | |
| } | |
| // Try first active ambient sound | |
| if (state.ambient.active.length > 0) { | |
| const firstActiveKey = state.ambient.active[0]; | |
| const audio = ambientAudios[firstActiveKey]; | |
| if (audio && !audio.paused) { | |
| window.connectAudioVisualizer(audio); | |
| console.log(`🎵 Visualizer connected to: ${AMBIENT_SOUNDS[firstActiveKey].name}`); | |
| return; | |
| } | |
| } | |
| // Nothing playing, disconnect | |
| if (typeof window.disconnectAudioVisualizer === "function") { | |
| window.disconnectAudioVisualizer(); | |
| } | |
| } | |
| function setAmbientVolume(value) { | |
| state.ambient.volume = value / 100; | |
| Object.values(ambientAudios).forEach(audio => { | |
| audio.volume = state.ambient.volume; | |
| }); | |
| saveAmbientSettings(); | |
| } | |
| function loadAmbientSettings() { | |
| try { | |
| const saved = localStorage.getItem("lofi-focus-ambient"); | |
| if (saved) { | |
| const settings = JSON.parse(saved); | |
| state.ambient.volume = settings.volume ?? 0.3; | |
| if (elements.ambientVolume) { | |
| elements.ambientVolume.value = state.ambient.volume * 100; | |
| } | |
| } | |
| } catch (e) { | |
| console.log("🌙 Could not load ambient settings"); | |
| } | |
| } | |
| function saveAmbientSettings() { | |
| try { | |
| localStorage.setItem( | |
| "lofi-focus-ambient", | |
| JSON.stringify({ | |
| volume: state.ambient.volume | |
| }) | |
| ); | |
| } catch (e) { | |
| console.log("🌙 Could not save ambient settings"); | |
| } | |
| } | |
| // ═══════════════════════════════════════════════════════════════════════ | |
| // CUSTOM TIMER DURATIONS 🎨 | |
| // ═══════════════════════════════════════════════════════════════════════ | |
| function updateCustomDuration(mode, value) { | |
| const duration = parseInt(value, 10); | |
| if (isNaN(duration) || duration < 1) return; | |
| state.customDurations[mode] = duration; | |
| MODES[mode].time = duration; | |
| // Update button text | |
| const btn = document.querySelector(`.mode-btn[data-mode="${mode}"]`); | |
| if (btn) { | |
| const timeSpan = btn.querySelector(".mode-time"); | |
| if (timeSpan) timeSpan.textContent = `${duration}m`; | |
| } | |
| // If current mode, update timer | |
| if (state.mode === mode && !state.isRunning) { | |
| state.totalTime = duration * 60; | |
| state.timeRemaining = state.totalTime; | |
| updateDisplay(); | |
| } | |
| saveSettings(); | |
| console.log(`⏱️ ${mode} duration set to ${duration} min`); | |
| } | |
| // ═══════════════════════════════════════════════════════════════════════ | |
| // DOM ELEMENTS | |
| // ═══════════════════════════════════════════════════════════════════════ | |
| const elements = { | |
| minutes: document.getElementById("minutes"), | |
| seconds: document.getElementById("seconds"), | |
| sessionType: document.getElementById("session-type"), | |
| sessionNumber: document.getElementById("session-number"), | |
| btnStart: document.getElementById("btn-start"), | |
| btnPause: document.getElementById("btn-pause"), | |
| btnReset: document.getElementById("btn-reset"), | |
| btnSettings: document.getElementById("btn-settings"), | |
| settingsPanel: document.getElementById("settings-panel"), | |
| soundToggle: document.getElementById("sound-toggle"), | |
| autoStartToggle: document.getElementById("auto-start"), | |
| progressRing: document.querySelector(".progress-ring__progress"), | |
| timerSection: document.querySelector(".timer-section"), | |
| modeButtons: document.querySelectorAll(".mode-btn"), | |
| // Radio elements | |
| btnRadio: document.getElementById("btn-radio"), | |
| radioIcon: document.getElementById("radio-icon"), | |
| radioStatus: document.getElementById("radio-status"), | |
| radioSelect: document.getElementById("radio-select"), | |
| radioVolume: document.getElementById("radio-volume"), | |
| radioPlayer: document.querySelector(".radio-player"), | |
| // Ambient elements | |
| ambientButtons: document.querySelectorAll(".ambient-btn"), | |
| ambientVolume: document.getElementById("ambient-volume"), | |
| // Notifications & custom durations | |
| notifToggle: document.getElementById("notif-toggle"), | |
| focusDuration: document.getElementById("focus-duration"), | |
| shortDuration: document.getElementById("short-duration"), | |
| longDuration: document.getElementById("long-duration") | |
| }; | |
| // ═══════════════════════════════════════════════════════════════════════ | |
| // RADIO PLAYER 🎵 | |
| // ═══════════════════════════════════════════════════════════════════════ | |
| function initRadio() { | |
| radioAudio = new Audio(); | |
| radioAudio.volume = state.radio.volume; | |
| radioAudio.crossOrigin = "anonymous"; | |
| radioAudio.addEventListener("playing", () => { | |
| state.radio.isPlaying = true; | |
| updateRadioUI(); | |
| console.log("🎵 Radio playing:", RADIO_STATIONS[state.radio.currentStation].name); | |
| }); | |
| radioAudio.addEventListener("pause", () => { | |
| state.radio.isPlaying = false; | |
| updateRadioUI(); | |
| }); | |
| radioAudio.addEventListener("error", e => { | |
| console.log("🎵 Radio error, trying to reconnect...", e); | |
| state.radio.isPlaying = false; | |
| updateRadioUI(); | |
| elements.radioStatus.textContent = "Connection error"; | |
| }); | |
| radioAudio.addEventListener("waiting", () => { | |
| elements.radioStatus.textContent = "Buffering..."; | |
| }); | |
| radioAudio.addEventListener("canplay", () => { | |
| if (state.radio.isPlaying) { | |
| elements.radioStatus.textContent = "Now Playing"; | |
| } | |
| }); | |
| // Load saved radio settings | |
| loadRadioSettings(); | |
| } | |
| function toggleRadio() { | |
| if (state.radio.isPlaying) { | |
| stopRadio(); | |
| } else { | |
| playRadio(); | |
| } | |
| } | |
| function playRadio() { | |
| const station = RADIO_STATIONS[state.radio.currentStation]; | |
| if (!station) return; | |
| elements.radioStatus.textContent = "Connecting..."; | |
| radioAudio.src = station.url; | |
| radioAudio | |
| .play() | |
| .then(() => { | |
| // Update visualizer connection (radio has priority) | |
| updateVisualizerConnection(); | |
| }) | |
| .catch(e => { | |
| console.log("🎵 Autoplay blocked, user interaction needed"); | |
| elements.radioStatus.textContent = "Click to play"; | |
| }); | |
| } | |
| function stopRadio() { | |
| radioAudio.pause(); | |
| radioAudio.src = ""; | |
| state.radio.isPlaying = false; | |
| updateRadioUI(); | |
| // Update visualizer (might switch to ambient if playing) | |
| updateVisualizerConnection(); | |
| console.log("🎵 Radio stopped"); | |
| } | |
| function changeStation(stationId) { | |
| state.radio.currentStation = stationId; | |
| saveRadioSettings(); | |
| if (state.radio.isPlaying) { | |
| playRadio(); | |
| } | |
| } | |
| function setVolume(value) { | |
| state.radio.volume = value / 100; | |
| if (radioAudio) { | |
| radioAudio.volume = state.radio.volume; | |
| } | |
| saveRadioSettings(); | |
| } | |
| function updateRadioUI() { | |
| if (state.radio.isPlaying) { | |
| elements.btnRadio.classList.add("playing"); | |
| elements.radioPlayer.classList.add("playing"); | |
| elements.radioStatus.classList.add("playing"); | |
| elements.radioIcon.textContent = "🔊"; | |
| elements.radioStatus.textContent = "Now Playing"; | |
| } else { | |
| elements.btnRadio.classList.remove("playing"); | |
| elements.radioPlayer.classList.remove("playing"); | |
| elements.radioStatus.classList.remove("playing"); | |
| elements.radioIcon.textContent = "🎵"; | |
| elements.radioStatus.textContent = "Radio Off"; | |
| } | |
| } | |
| function loadRadioSettings() { | |
| try { | |
| const saved = localStorage.getItem("lofi-focus-radio"); | |
| if (saved) { | |
| const settings = JSON.parse(saved); | |
| state.radio = { ...state.radio, ...settings }; | |
| elements.radioSelect.value = state.radio.currentStation; | |
| elements.radioVolume.value = state.radio.volume * 100; | |
| if (radioAudio) { | |
| radioAudio.volume = state.radio.volume; | |
| } | |
| } | |
| } catch (e) { | |
| console.log("🎵 Could not load radio settings"); | |
| } | |
| } | |
| function saveRadioSettings() { | |
| try { | |
| localStorage.setItem( | |
| "lofi-focus-radio", | |
| JSON.stringify({ | |
| currentStation: state.radio.currentStation, | |
| volume: state.radio.volume | |
| }) | |
| ); | |
| } catch (e) { | |
| console.log("🎵 Could not save radio settings"); | |
| } | |
| } | |
| // ═══════════════════════════════════════════════════════════════════════ | |
| // AUDIO (simple beep using Web Audio API) | |
| // ═══════════════════════════════════════════════════════════════════════ | |
| function getAudioContext() { | |
| if (!audioContext || audioContext.state === "closed") { | |
| audioContext = new (window.AudioContext || window.webkitAudioContext)(); | |
| } | |
| // Resume if suspended (browser autoplay policy) | |
| if (audioContext.state === "suspended") { | |
| audioContext.resume(); | |
| } | |
| return audioContext; | |
| } | |
| function playBeep(frequency = 800, duration = 0.3, delay = 0) { | |
| try { | |
| const ctx = getAudioContext(); | |
| const startTime = ctx.currentTime + delay; | |
| const oscillator = ctx.createOscillator(); | |
| const gainNode = ctx.createGain(); | |
| oscillator.connect(gainNode); | |
| gainNode.connect(ctx.destination); | |
| oscillator.frequency.value = frequency; | |
| oscillator.type = "sine"; | |
| gainNode.gain.setValueAtTime(0.3, startTime); | |
| gainNode.gain.exponentialRampToValueAtTime(0.01, startTime + duration); | |
| oscillator.start(startTime); | |
| oscillator.stop(startTime + duration); | |
| } catch (e) { | |
| console.log("⚡ Audio not supported"); | |
| } | |
| } | |
| function playNotificationSound() { | |
| if (!state.settings.soundEnabled) return; | |
| // Play 3 beeps with slight delays | |
| playBeep(800, 0.3, 0); | |
| playBeep(800, 0.3, 0.2); | |
| playBeep(1000, 0.4, 0.4); // Last beep slightly higher | |
| } | |
| // ═══════════════════════════════════════════════════════════════════════ | |
| // TIMER LOGIC | |
| // ═══════════════════════════════════════════════════════════════════════ | |
| function startTimer() { | |
| if (state.isRunning) return; | |
| state.isRunning = true; | |
| elements.timerSection.classList.remove("timer-paused"); | |
| updateControlButtons(); | |
| state.intervalId = setInterval(() => { | |
| state.timeRemaining--; | |
| if (state.timeRemaining <= 0) { | |
| timerComplete(); | |
| } else { | |
| updateDisplay(); | |
| } | |
| }, 1000); | |
| console.log("⚡ Timer started!"); | |
| } | |
| function pauseTimer() { | |
| if (!state.isRunning) return; | |
| state.isRunning = false; | |
| elements.timerSection.classList.add("timer-paused"); | |
| clearInterval(state.intervalId); | |
| updateControlButtons(); | |
| console.log("⏸ Timer paused"); | |
| } | |
| function resetTimer() { | |
| pauseTimer(); | |
| state.timeRemaining = state.totalTime; | |
| updateDisplay(); | |
| console.log("↺ Timer reset"); | |
| } | |
| function timerComplete() { | |
| pauseTimer(); | |
| playNotificationSound(); | |
| // Send browser notification 🔔 | |
| const modeLabel = MODES[state.mode].label; | |
| if (state.mode === "focus") { | |
| sendNotification("⚡ Focus Complete!", "Time for a break. You earned it!"); | |
| } else { | |
| sendNotification("☕ Break Over!", "Ready to focus again?"); | |
| } | |
| console.log("🎉 Timer complete!"); | |
| // Determine next mode | |
| if (state.mode === "focus") { | |
| // Long break every 4 completed focus sessions | |
| if (state.sessionCount % SESSIONS_BEFORE_LONG_BREAK === 0) { | |
| setMode("long"); | |
| } else { | |
| setMode("short"); | |
| } | |
| // Increment session count AFTER deciding break type | |
| state.sessionCount++; | |
| } else { | |
| // After any break, go back to focus | |
| setMode("focus"); | |
| } | |
| // Auto-start if enabled | |
| if (state.settings.autoStartBreaks) { | |
| setTimeout(startTimer, 1000); | |
| } | |
| } | |
| // ═══════════════════════════════════════════════════════════════════════ | |
| // MODE MANAGEMENT | |
| // ═══════════════════════════════════════════════════════════════════════ | |
| function setMode(mode) { | |
| if (!MODES[mode]) return; | |
| // If timer is running, pause first | |
| if (state.isRunning) { | |
| pauseTimer(); | |
| } | |
| state.mode = mode; | |
| state.totalTime = MODES[mode].time * 60; | |
| state.timeRemaining = state.totalTime; | |
| // Update body attribute for CSS theming | |
| document.body.setAttribute("data-mode", mode); | |
| // Update session type label | |
| elements.sessionType.textContent = MODES[mode].label; | |
| // Update mode buttons | |
| elements.modeButtons.forEach(btn => { | |
| btn.classList.toggle("active", btn.dataset.mode === mode); | |
| }); | |
| // Update visualizer colors! 🎨 | |
| if (typeof window.setVisualizerMode === "function") { | |
| window.setVisualizerMode(mode); | |
| } | |
| updateDisplay(); | |
| console.log(`⚡ Mode changed to: ${mode}`); | |
| } | |
| // ═══════════════════════════════════════════════════════════════════════ | |
| // DISPLAY UPDATES | |
| // ═══════════════════════════════════════════════════════════════════════ | |
| function updateDisplay() { | |
| const minutes = Math.floor(state.timeRemaining / 60); | |
| const seconds = state.timeRemaining % 60; | |
| elements.minutes.textContent = String(minutes).padStart(2, "0"); | |
| elements.seconds.textContent = String(seconds).padStart(2, "0"); | |
| elements.sessionNumber.textContent = state.sessionCount; | |
| updateProgressRing(); | |
| updateDocumentTitle(minutes, seconds); | |
| } | |
| function updateProgressRing() { | |
| const progress = state.timeRemaining / state.totalTime; | |
| const offset = RING_CIRCUMFERENCE * (1 - progress); | |
| elements.progressRing.style.strokeDashoffset = offset; | |
| } | |
| function updateDocumentTitle(minutes, seconds) { | |
| const timeStr = `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`; | |
| const modeEmoji = state.mode === "focus" ? "⚡" : "☕"; | |
| document.title = `${timeStr} ${modeEmoji} Lo-fi Focus`; | |
| } | |
| function updateControlButtons() { | |
| if (state.isRunning) { | |
| elements.btnStart.classList.add("hidden"); | |
| elements.btnPause.classList.remove("hidden"); | |
| } else { | |
| elements.btnStart.classList.remove("hidden"); | |
| elements.btnPause.classList.add("hidden"); | |
| } | |
| } | |
| // ═══════════════════════════════════════════════════════════════════════ | |
| // SETTINGS | |
| // ═══════════════════════════════════════════════════════════════════════ | |
| function toggleSettings() { | |
| elements.settingsPanel.classList.toggle("hidden"); | |
| } | |
| function loadSettings() { | |
| try { | |
| const saved = localStorage.getItem("lofi-focus-settings"); | |
| if (saved) { | |
| const parsed = JSON.parse(saved); | |
| state.settings = { ...state.settings, ...parsed }; | |
| elements.soundToggle.checked = state.settings.soundEnabled; | |
| elements.autoStartToggle.checked = state.settings.autoStartBreaks; | |
| } | |
| // Load custom durations | |
| const durations = localStorage.getItem("lofi-focus-durations"); | |
| if (durations) { | |
| state.customDurations = { ...state.customDurations, ...JSON.parse(durations) }; | |
| // Apply to MODES | |
| MODES.focus.time = state.customDurations.focus; | |
| MODES.short.time = state.customDurations.short; | |
| MODES.long.time = state.customDurations.long; | |
| // Update inputs | |
| if (elements.focusDuration) elements.focusDuration.value = state.customDurations.focus; | |
| if (elements.shortDuration) elements.shortDuration.value = state.customDurations.short; | |
| if (elements.longDuration) elements.longDuration.value = state.customDurations.long; | |
| // Update button labels | |
| document.querySelectorAll(".mode-btn").forEach(btn => { | |
| const mode = btn.dataset.mode; | |
| const timeSpan = btn.querySelector(".mode-time"); | |
| if (timeSpan && state.customDurations[mode]) { | |
| timeSpan.textContent = `${state.customDurations[mode]}m`; | |
| } | |
| }); | |
| } | |
| // Load notification preference | |
| const notifPref = localStorage.getItem("lofi-focus-notif"); | |
| if (notifPref === "true" && Notification.permission === "granted") { | |
| state.notificationsEnabled = true; | |
| if (elements.notifToggle) elements.notifToggle.checked = true; | |
| } | |
| } catch (e) { | |
| console.log("⚡ Could not load settings, using defaults"); | |
| } | |
| } | |
| function saveSettings() { | |
| try { | |
| localStorage.setItem("lofi-focus-settings", JSON.stringify(state.settings)); | |
| localStorage.setItem("lofi-focus-durations", JSON.stringify(state.customDurations)); | |
| localStorage.setItem("lofi-focus-notif", state.notificationsEnabled.toString()); | |
| } catch (e) { | |
| console.log("⚡ Could not save settings"); | |
| } | |
| } | |
| // ═══════════════════════════════════════════════════════════════════════ | |
| // EVENT LISTENERS | |
| // ═══════════════════════════════════════════════════════════════════════ | |
| function setupEventListeners() { | |
| // Timer controls | |
| elements.btnStart.addEventListener("click", startTimer); | |
| elements.btnPause.addEventListener("click", pauseTimer); | |
| elements.btnReset.addEventListener("click", resetTimer); | |
| // Mode buttons | |
| elements.modeButtons.forEach(btn => { | |
| btn.addEventListener("click", () => { | |
| setMode(btn.dataset.mode); | |
| }); | |
| }); | |
| // Settings | |
| elements.btnSettings.addEventListener("click", toggleSettings); | |
| elements.soundToggle.addEventListener("change", e => { | |
| state.settings.soundEnabled = e.target.checked; | |
| saveSettings(); | |
| }); | |
| elements.autoStartToggle.addEventListener("change", e => { | |
| state.settings.autoStartBreaks = e.target.checked; | |
| saveSettings(); | |
| }); | |
| // Radio controls 🎵 | |
| elements.btnRadio.addEventListener("click", toggleRadio); | |
| elements.radioSelect.addEventListener("change", e => { | |
| changeStation(e.target.value); | |
| }); | |
| elements.radioVolume.addEventListener("input", e => { | |
| setVolume(e.target.value); | |
| }); | |
| // Ambient sounds 🌙 | |
| elements.ambientButtons.forEach(btn => { | |
| btn.addEventListener("click", () => { | |
| toggleAmbientSound(btn.dataset.sound); | |
| }); | |
| }); | |
| if (elements.ambientVolume) { | |
| elements.ambientVolume.addEventListener("input", e => { | |
| setAmbientVolume(e.target.value); | |
| }); | |
| } | |
| // Browser notifications 🔔 | |
| if (elements.notifToggle) { | |
| elements.notifToggle.addEventListener("change", e => { | |
| if (e.target.checked) { | |
| requestNotificationPermission(); | |
| } else { | |
| state.notificationsEnabled = false; | |
| saveSettings(); | |
| } | |
| }); | |
| } | |
| // Custom durations 🎨 | |
| if (elements.focusDuration) { | |
| elements.focusDuration.addEventListener("change", e => { | |
| updateCustomDuration("focus", e.target.value); | |
| }); | |
| } | |
| if (elements.shortDuration) { | |
| elements.shortDuration.addEventListener("change", e => { | |
| updateCustomDuration("short", e.target.value); | |
| }); | |
| } | |
| if (elements.longDuration) { | |
| elements.longDuration.addEventListener("change", e => { | |
| updateCustomDuration("long", e.target.value); | |
| }); | |
| } | |
| // Keyboard shortcuts | |
| document.addEventListener("keydown", e => { | |
| // Ignore if typing in an input | |
| if (e.target.tagName === "INPUT" || e.target.tagName === "SELECT") return; | |
| // Space to start/pause | |
| if (e.code === "Space") { | |
| e.preventDefault(); | |
| state.isRunning ? pauseTimer() : startTimer(); | |
| } | |
| // R to reset | |
| if (e.code === "KeyR") { | |
| e.preventDefault(); | |
| resetTimer(); | |
| } | |
| // 1, 2, 3 for modes | |
| if (e.code === "Digit1") setMode("focus"); | |
| if (e.code === "Digit2") setMode("short"); | |
| if (e.code === "Digit3") setMode("long"); | |
| // M to toggle music/radio | |
| if (e.code === "KeyM") { | |
| e.preventDefault(); | |
| toggleRadio(); | |
| } | |
| }); | |
| } | |
| // ═══════════════════════════════════════════════════════════════════════ | |
| // INITIALIZATION | |
| // ═══════════════════════════════════════════════════════════════════════ | |
| function init() { | |
| loadSettings(); | |
| initRadio(); | |
| initAmbient(); | |
| setupEventListeners(); | |
| setMode("focus"); | |
| updateDisplay(); | |
| setupAboutModal(); | |
| console.log("⚡ Lo-fi Focus Timer initialized!"); | |
| console.log("💙 Made with love by Kai"); | |
| console.log("─────────────────────────────────"); | |
| console.log("Keyboard shortcuts:"); | |
| console.log(" [Space] Start/Pause timer"); | |
| console.log(" [R] Reset timer"); | |
| console.log(" [1] Focus mode"); | |
| console.log(" [2] Short break"); | |
| console.log(" [3] Long break"); | |
| console.log(" [M] Toggle radio 🎵"); | |
| console.log("─────────────────────────────────"); | |
| console.log("🌙 Ambient sounds ready"); | |
| console.log("🔔 Browser notifications: " + (Notification.permission === "granted" ? "enabled" : "click to enable")); | |
| } | |
| // ═══════════════════════════════════════════════════════════════════════ | |
| // ABOUT MODAL ⚡ | |
| // ═══════════════════════════════════════════════════════════════════════ | |
| function setupAboutModal() { | |
| const modal = document.getElementById("about-modal"); | |
| const btnAbout = document.getElementById("btn-about"); | |
| const btnAbout2 = document.getElementById("btn-about-2"); | |
| const btnClose = document.getElementById("modal-close"); | |
| const overlay = modal?.querySelector(".modal-overlay"); | |
| if (!modal) return; | |
| function openModal(e) { | |
| e.preventDefault(); | |
| modal.classList.remove("hidden"); | |
| document.body.style.overflow = "hidden"; | |
| } | |
| function closeModal() { | |
| modal.classList.add("hidden"); | |
| document.body.style.overflow = ""; | |
| } | |
| btnAbout?.addEventListener("click", openModal); | |
| btnAbout2?.addEventListener("click", openModal); | |
| btnClose?.addEventListener("click", closeModal); | |
| overlay?.addEventListener("click", closeModal); | |
| // Close on Escape key | |
| document.addEventListener("keydown", e => { | |
| if (e.key === "Escape" && !modal.classList.contains("hidden")) { | |
| closeModal(); | |
| } | |
| }); | |
| } | |
| // Start the app | |
| init(); | |
| })(); | |