/** * 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,", 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(); })();