| | |
| | |
| | |
| | |
| | |
| | |
| |
|
| |
|
| | (function () {
|
| | "use strict";
|
| |
|
| |
|
| |
|
| |
|
| |
|
| |
|
| | 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" }
|
| | };
|
| |
|
| |
|
| | 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;
|
| | const SESSIONS_BEFORE_LONG_BREAK = 4;
|
| |
|
| |
|
| | const RADIO_STATIONS = {
|
| |
|
| | "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-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"
|
| | },
|
| |
|
| | "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"
|
| | }
|
| | };
|
| |
|
| |
|
| |
|
| |
|
| |
|
| | let state = {
|
| | mode: "focus",
|
| | timeRemaining: MODES.focus.time * 60,
|
| | totalTime: MODES.focus.time * 60,
|
| | isRunning: false,
|
| | sessionCount: 1,
|
| | intervalId: null,
|
| | settings: {
|
| | soundEnabled: true,
|
| | autoStartBreaks: false,
|
| | effectsEnabled: true
|
| | },
|
| | radio: {
|
| | isPlaying: false,
|
| | currentStation: "fip-groove",
|
| | volume: 0.5
|
| | },
|
| | ambient: {
|
| | active: [],
|
| | volume: 0.3
|
| | },
|
| | customDurations: {
|
| | focus: 25,
|
| | short: 5,
|
| | long: 15
|
| | },
|
| | notificationsEnabled: false
|
| | };
|
| |
|
| |
|
| | let radioAudio = null;
|
| |
|
| |
|
| | let ambientAudios = {};
|
| |
|
| |
|
| | let audioContext = null;
|
| |
|
| |
|
| |
|
| |
|
| |
|
| | function requestNotificationPermission() {
|
| | if (!("Notification" in window)) {
|
| | console.log("🔔 Notifications not supported");
|
| | alert("🔔 Browser notifications are not supported on your device.");
|
| | return;
|
| | }
|
| |
|
| | if (Notification.permission === "granted") {
|
| | state.notificationsEnabled = true;
|
| | } else if (Notification.permission === "denied") {
|
| | alert("🚫 Notifications are blocked. Please enable them in your browser settings.");
|
| | if (elements.notifToggle) elements.notifToggle.checked = false;
|
| | } else if (Notification.permission !== "denied") {
|
| | Notification.requestPermission().then(permission => {
|
| | state.notificationsEnabled = permission === "granted";
|
| | if (elements.notifToggle) {
|
| | elements.notifToggle.checked = state.notificationsEnabled;
|
| | }
|
| | if (permission === "denied") {
|
| | alert("🚫 Notifications blocked. You can enable them later in settings.");
|
| | }
|
| | 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
|
| | });
|
| |
|
| |
|
| | setTimeout(() => notif.close(), 5000);
|
| |
|
| |
|
| | notif.onclick = () => {
|
| | window.focus();
|
| | notif.close();
|
| | };
|
| | } catch (e) {
|
| | console.log("🔔 Notification failed:", e);
|
| | }
|
| | }
|
| |
|
| |
|
| |
|
| |
|
| |
|
| | function initAmbient() {
|
| |
|
| |
|
| | loadAmbientSettings();
|
| | }
|
| |
|
| | function getOrCreateAmbientAudio(soundKey) {
|
| |
|
| | if (!ambientAudios[soundKey]) {
|
| | const audio = new Audio();
|
| | audio.loop = true;
|
| | audio.volume = state.ambient.volume;
|
| | audio.src = AMBIENT_SOUNDS[soundKey].file;
|
| | audio.preload = "metadata";
|
| | ambientAudios[soundKey] = audio;
|
| | console.log(`🎵 Created audio for: ${AMBIENT_SOUNDS[soundKey].name}`);
|
| | }
|
| | return ambientAudios[soundKey];
|
| | }
|
| |
|
| | function toggleAmbientSound(soundKey) {
|
| | const audio = getOrCreateAmbientAudio(soundKey);
|
| | const btn = document.querySelector(`.ambient-btn[data-sound="${soundKey}"]`);
|
| |
|
| | if (!audio || !btn) return;
|
| |
|
| | if (state.ambient.active.includes(soundKey)) {
|
| |
|
| | audio.pause();
|
| | audio.currentTime = 0;
|
| | state.ambient.active = state.ambient.active.filter(s => s !== soundKey);
|
| | btn.classList.remove("active");
|
| | btn.setAttribute("aria-pressed", "false");
|
| | console.log(`🌙 Stopped: ${AMBIENT_SOUNDS[soundKey].name}`);
|
| |
|
| |
|
| | updateVisualizerConnection();
|
| | } else {
|
| |
|
| | audio.volume = state.ambient.volume;
|
| |
|
| |
|
| | btn.classList.add("loading");
|
| |
|
| | audio
|
| | .play()
|
| | .then(() => {
|
| |
|
| | btn.classList.remove("loading");
|
| | btn.classList.add("active");
|
| | btn.setAttribute("aria-pressed", "true");
|
| | state.ambient.active.push(soundKey);
|
| | console.log(`🌙 Playing: ${AMBIENT_SOUNDS[soundKey].name}`);
|
| |
|
| |
|
| | updateVisualizerConnection();
|
| | })
|
| | .catch(e => {
|
| | console.log(`🌙 Could not play ${soundKey}:`, e.message);
|
| | btn.classList.remove("loading");
|
| | btn.classList.add("error");
|
| | setTimeout(() => btn.classList.remove("error"), 2000);
|
| | });
|
| | }
|
| |
|
| | saveAmbientSettings();
|
| | }
|
| |
|
| |
|
| | function updateVisualizerConnection() {
|
| | if (typeof window.connectAudioVisualizer !== "function") return;
|
| |
|
| |
|
| | if (state.radio.isPlaying && radioAudio) {
|
| | window.connectAudioVisualizer(radioAudio);
|
| | return;
|
| | }
|
| |
|
| |
|
| | 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;
|
| | }
|
| | }
|
| |
|
| |
|
| | 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() {
|
| | clearTimeout(saveAmbientTimeout);
|
| | saveAmbientTimeout = setTimeout(() => {
|
| | try {
|
| | localStorage.setItem(
|
| | "lofi-focus-ambient",
|
| | JSON.stringify({
|
| | volume: state.ambient.volume
|
| | })
|
| | );
|
| | } catch (e) {
|
| | console.log("🌙 Could not save ambient settings");
|
| | }
|
| | }, 500);
|
| | }
|
| |
|
| |
|
| |
|
| |
|
| |
|
| | function updateCustomDuration(mode, value) {
|
| | const duration = parseInt(value, 10);
|
| |
|
| | const maxDurations = { focus: 120, short: 30, long: 60 };
|
| | const maxDuration = maxDurations[mode] || 120;
|
| |
|
| | if (isNaN(duration) || duration < 1 || duration > maxDuration) {
|
| |
|
| | const input = document.getElementById(`${mode}-duration`);
|
| | if (input) input.value = state.customDurations[mode];
|
| | return;
|
| | }
|
| |
|
| | state.customDurations[mode] = duration;
|
| | MODES[mode].time = duration;
|
| |
|
| |
|
| | const btn = document.querySelector(`.mode-btn[data-mode="${mode}"]`);
|
| | if (btn) {
|
| | const timeSpan = btn.querySelector(".mode-time");
|
| | if (timeSpan) timeSpan.textContent = `${duration}m`;
|
| | }
|
| |
|
| |
|
| | if (state.mode === mode && !state.isRunning) {
|
| | state.totalTime = duration * 60;
|
| | state.timeRemaining = state.totalTime;
|
| | updateDisplay();
|
| | }
|
| |
|
| | saveSettings();
|
| | console.log(`⏱️ ${mode} duration set to ${duration} min`);
|
| | }
|
| |
|
| |
|
| |
|
| |
|
| |
|
| | 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"),
|
| | effectsToggle: document.getElementById("effects-toggle"),
|
| | progressRing: document.querySelector(".progress-ring__progress"),
|
| | timerSection: document.querySelector(".timer-section"),
|
| | modeButtons: document.querySelectorAll(".mode-btn"),
|
| |
|
| | 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"),
|
| |
|
| | ambientButtons: document.querySelectorAll(".ambient-btn"),
|
| | ambientVolume: document.getElementById("ambient-volume"),
|
| |
|
| | notifToggle: document.getElementById("notif-toggle"),
|
| | focusDuration: document.getElementById("focus-duration"),
|
| | shortDuration: document.getElementById("short-duration"),
|
| | longDuration: document.getElementById("long-duration")
|
| | };
|
| |
|
| |
|
| |
|
| |
|
| |
|
| | 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";
|
| | }
|
| | });
|
| |
|
| |
|
| | 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...";
|
| | elements.radioPlayer.classList.add("loading");
|
| | radioAudio.src = station.url;
|
| | radioAudio
|
| | .play()
|
| | .then(() => {
|
| | elements.radioPlayer.classList.remove("loading");
|
| |
|
| | updateVisualizerConnection();
|
| | })
|
| | .catch(e => {
|
| | console.log("🎵 Autoplay blocked, user interaction needed");
|
| | elements.radioStatus.textContent = "Click to play";
|
| | elements.radioPlayer.classList.remove("loading");
|
| | elements.radioPlayer.classList.add("error");
|
| | setTimeout(() => elements.radioPlayer.classList.remove("error"), 2000);
|
| | });
|
| | }
|
| |
|
| | function stopRadio() {
|
| | radioAudio.pause();
|
| | radioAudio.src = "";
|
| | state.radio.isPlaying = false;
|
| | updateRadioUI();
|
| |
|
| | 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");
|
| | }
|
| | }
|
| |
|
| |
|
| | let saveRadioTimeout;
|
| | let saveAmbientTimeout;
|
| |
|
| | function saveRadioSettings() {
|
| | clearTimeout(saveRadioTimeout);
|
| | saveRadioTimeout = setTimeout(() => {
|
| | 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");
|
| | }
|
| | }, 500);
|
| | }
|
| |
|
| |
|
| |
|
| |
|
| |
|
| | function getAudioContext() {
|
| | if (!audioContext || audioContext.state === "closed") {
|
| | audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
| | }
|
| |
|
| | 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;
|
| |
|
| |
|
| | playBeep(800, 0.3, 0);
|
| | playBeep(800, 0.3, 0.2);
|
| | playBeep(1000, 0.4, 0.4);
|
| | }
|
| |
|
| |
|
| |
|
| |
|
| |
|
| | 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();
|
| |
|
| |
|
| | 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!");
|
| |
|
| |
|
| | if (state.mode === "focus") {
|
| |
|
| |
|
| | if (state.sessionCount % SESSIONS_BEFORE_LONG_BREAK === 0) {
|
| | setMode("long");
|
| | } else {
|
| | setMode("short");
|
| | }
|
| |
|
| | state.sessionCount++;
|
| | } else {
|
| |
|
| | setMode("focus");
|
| | }
|
| |
|
| |
|
| | if (state.settings.autoStartBreaks) {
|
| |
|
| | let countdown = 3;
|
| | const originalLabel = MODES[state.mode].label;
|
| | const countdownInterval = setInterval(() => {
|
| | elements.sessionType.textContent = `${originalLabel} in ${countdown}...`;
|
| | countdown--;
|
| | if (countdown < 0) {
|
| | clearInterval(countdownInterval);
|
| | elements.sessionType.textContent = originalLabel;
|
| | }
|
| | }, 1000);
|
| | setTimeout(() => {
|
| | startTimer();
|
| | }, 3000);
|
| | }
|
| | }
|
| |
|
| |
|
| |
|
| |
|
| |
|
| | function setMode(mode) {
|
| | if (!MODES[mode]) return;
|
| |
|
| |
|
| | if (state.isRunning) {
|
| | pauseTimer();
|
| | }
|
| |
|
| | state.mode = mode;
|
| | state.totalTime = MODES[mode].time * 60;
|
| | state.timeRemaining = state.totalTime;
|
| |
|
| |
|
| | document.body.setAttribute("data-mode", mode);
|
| |
|
| |
|
| | elements.sessionType.textContent = MODES[mode].label;
|
| |
|
| |
|
| | elements.modeButtons.forEach(btn => {
|
| | btn.classList.toggle("active", btn.dataset.mode === mode);
|
| | });
|
| |
|
| |
|
| | if (typeof window.setVisualizerMode === "function") {
|
| | window.setVisualizerMode(mode);
|
| | }
|
| |
|
| | updateDisplay();
|
| | console.log(`⚡ Mode changed to: ${mode}`);
|
| | }
|
| |
|
| |
|
| |
|
| |
|
| |
|
| | 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;
|
| |
|
| |
|
| | const progressPercent = Math.round(progress * 100);
|
| | const progressRingSvg = document.querySelector(".progress-ring");
|
| | if (progressRingSvg) {
|
| | progressRingSvg.setAttribute("aria-valuenow", progressPercent);
|
| | }
|
| | }
|
| |
|
| | 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");
|
| | }
|
| | }
|
| |
|
| |
|
| |
|
| |
|
| |
|
| | function toggleSettings() {
|
| | const isHidden = elements.settingsPanel.classList.toggle("hidden");
|
| | elements.btnSettings.setAttribute("aria-expanded", !isHidden);
|
| | }
|
| |
|
| | 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;
|
| | if (elements.effectsToggle) {
|
| | elements.effectsToggle.checked = state.settings.effectsEnabled ?? true;
|
| | }
|
| | }
|
| |
|
| |
|
| | const durations = localStorage.getItem("lofi-focus-durations");
|
| | if (durations) {
|
| | state.customDurations = { ...state.customDurations, ...JSON.parse(durations) };
|
| |
|
| | MODES.focus.time = state.customDurations.focus;
|
| | MODES.short.time = state.customDurations.short;
|
| | MODES.long.time = state.customDurations.long;
|
| |
|
| | 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;
|
| |
|
| | 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`;
|
| | }
|
| | });
|
| | }
|
| |
|
| |
|
| | 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");
|
| | }
|
| | }
|
| |
|
| |
|
| |
|
| |
|
| |
|
| | function setupEventListeners() {
|
| |
|
| | elements.btnStart.addEventListener("click", startTimer);
|
| | elements.btnPause.addEventListener("click", pauseTimer);
|
| | elements.btnReset.addEventListener("click", resetTimer);
|
| |
|
| |
|
| | elements.modeButtons.forEach(btn => {
|
| | btn.addEventListener("click", () => {
|
| | setMode(btn.dataset.mode);
|
| | });
|
| | });
|
| |
|
| |
|
| | 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();
|
| | });
|
| |
|
| | if (elements.effectsToggle) {
|
| | elements.effectsToggle.addEventListener("change", e => {
|
| | state.settings.effectsEnabled = e.target.checked;
|
| | toggleVisualEffects(e.target.checked);
|
| | saveSettings();
|
| | });
|
| | }
|
| |
|
| |
|
| | elements.btnRadio.addEventListener("click", toggleRadio);
|
| |
|
| | elements.radioSelect.addEventListener("change", e => {
|
| | changeStation(e.target.value);
|
| | });
|
| |
|
| | elements.radioVolume.addEventListener("input", e => {
|
| | setVolume(e.target.value);
|
| | });
|
| |
|
| |
|
| | elements.ambientButtons.forEach(btn => {
|
| | btn.addEventListener("click", () => {
|
| | toggleAmbientSound(btn.dataset.sound);
|
| | });
|
| | });
|
| |
|
| | if (elements.ambientVolume) {
|
| | elements.ambientVolume.addEventListener("input", e => {
|
| | setAmbientVolume(e.target.value);
|
| | });
|
| | }
|
| |
|
| |
|
| | if (elements.notifToggle) {
|
| | elements.notifToggle.addEventListener("change", e => {
|
| | if (e.target.checked) {
|
| | requestNotificationPermission();
|
| | } else {
|
| | state.notificationsEnabled = false;
|
| | saveSettings();
|
| | }
|
| | });
|
| | }
|
| |
|
| |
|
| | 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);
|
| | });
|
| | }
|
| |
|
| |
|
| | document.addEventListener("keydown", e => {
|
| |
|
| | if (e.target.tagName === "INPUT" || e.target.tagName === "SELECT") return;
|
| |
|
| |
|
| | if (e.code === "Space") {
|
| | e.preventDefault();
|
| | state.isRunning ? pauseTimer() : startTimer();
|
| | }
|
| |
|
| | if (e.code === "KeyR") {
|
| | e.preventDefault();
|
| | resetTimer();
|
| | }
|
| |
|
| | if (e.code === "Digit1") setMode("focus");
|
| | if (e.code === "Digit2") setMode("short");
|
| | if (e.code === "Digit3") setMode("long");
|
| |
|
| | if (e.code === "KeyM") {
|
| | e.preventDefault();
|
| | toggleRadio();
|
| | }
|
| | });
|
| | }
|
| |
|
| |
|
| |
|
| |
|
| |
|
| | function toggleVisualEffects(enabled) {
|
| | const canvas = document.getElementById("lightning-canvas");
|
| | if (!canvas) return;
|
| |
|
| | if (enabled) {
|
| | canvas.style.display = "block";
|
| | canvas.style.opacity = "1";
|
| |
|
| | if (typeof window.resumeVisualEffects === "function") {
|
| | window.resumeVisualEffects();
|
| | }
|
| | console.log("✨ Visual effects enabled");
|
| | } else {
|
| | canvas.style.opacity = "0";
|
| |
|
| | setTimeout(() => {
|
| | canvas.style.display = "none";
|
| | }, 300);
|
| |
|
| | if (typeof window.pauseVisualEffects === "function") {
|
| | window.pauseVisualEffects();
|
| | }
|
| | console.log("✨ Visual effects disabled (better performance)");
|
| | }
|
| | }
|
| |
|
| |
|
| |
|
| |
|
| |
|
| | function init() {
|
| | loadSettings();
|
| | initRadio();
|
| | initAmbient();
|
| | setupEventListeners();
|
| | setMode("focus");
|
| | updateDisplay();
|
| | setupAboutModal();
|
| | setupShortcutsModal();
|
| |
|
| |
|
| | toggleVisualEffects(state.settings.effectsEnabled);
|
| |
|
| | 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")
|
| | );
|
| | }
|
| |
|
| |
|
| |
|
| |
|
| |
|
| | 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);
|
| |
|
| |
|
| | document.addEventListener("keydown", e => {
|
| | if (e.key === "Escape" && !modal.classList.contains("hidden")) {
|
| | closeModal();
|
| | }
|
| | });
|
| | }
|
| |
|
| |
|
| |
|
| |
|
| |
|
| | function setupShortcutsModal() {
|
| | const modal = document.getElementById("shortcuts-modal");
|
| | const btnShortcuts = document.getElementById("btn-shortcuts");
|
| | const btnClose = document.getElementById("shortcuts-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 = "";
|
| | }
|
| |
|
| | btnShortcuts?.addEventListener("click", openModal);
|
| | btnClose?.addEventListener("click", closeModal);
|
| | overlay?.addEventListener("click", closeModal);
|
| |
|
| |
|
| | document.addEventListener("keydown", e => {
|
| | if (e.key === "?" && modal.classList.contains("hidden")) {
|
| | e.preventDefault();
|
| | openModal(e);
|
| | }
|
| |
|
| | if (e.key === "Escape" && !modal.classList.contains("hidden")) {
|
| | closeModal();
|
| | }
|
| | });
|
| | }
|
| |
|
| |
|
| | init();
|
| | })();
|
| |
|