diff --git a/.changeset/neat-games-rescue.md b/.changeset/neat-games-rescue.md new file mode 100644 index 0000000..158a978 --- /dev/null +++ b/.changeset/neat-games-rescue.md @@ -0,0 +1,5 @@ +--- +'studytimer.io': minor +--- + +Add audio alerts for timer events and settings customization. diff --git a/src/app.js b/src/app.js index 43c9247..d5ae5ca 100644 --- a/src/app.js +++ b/src/app.js @@ -2,23 +2,30 @@ import { Router } from '@lit-labs/router'; import { LitElement, css, html, nothing } from 'lit'; import { notificationApiService } from './services/notification-api.service.js'; +import { webAudioApiService } from './services/web-audio-api.service.js'; import { buttonStyles } from './shared/styles/buttonStyles.js'; import { captionTextStyles } from './shared/styles/captionTextStyles.js'; import { linkStyles } from './shared/styles/linkStyles.js'; import { modalStyles } from './shared/styles/modalStyles.js'; +import { tooltipStyles } from './shared/styles/tooltipStyles.js'; import { appStore } from './stores/app.js'; import { DEFAULT_SETTINGS, settingsStore } from './stores/settings.js'; import { + AUDIO_SOUND, + AUDIO_VOLUME, CLIENT_ERROR_MESSAGE, NOTIFICATION_PERMISSION, SETTINGS_EVENT, STORAGE_KEY_NAMESPACE, } from './utils/constants.js'; -import { isBool, isNum } from './utils/helpers.js'; +import { isBool, isNum, toSentenceCase } from './utils/helpers.js'; import './components/app-top-bar.js'; import './components/header.js'; +const AUDIO_SOUNDS = Object.freeze(Object.values(AUDIO_SOUND)); +const AUDIO_VOLUMES = Object.freeze(Object.values(AUDIO_VOLUME)); + const POMODORO_RESOURCE_LINKS = Object.freeze({ WIKI: 'https://en.wikipedia.org/wiki/Pomodoro_Technique', VIDEO: 'https://youtu.be/dC4ZYCiRF_w?si=ekRqmmWpnqrAZM-c&t=8', @@ -85,6 +92,11 @@ export class App extends LitElement { connectedCallback() { super.connectedCallback(); + window.addEventListener('click', this.#initAudio, { once: true }); + window.addEventListener('pointerup', this.#initAudio, { + once: true, + }); + window.addEventListener('keydown', this.#initAudio, { once: true }); this._settingsFormValues = { ...settingsStore.settings }; } @@ -257,6 +269,8 @@ export class App extends LitElement { pomodoroMinutes, shortBreakMinutes, longBreakMinutes, + audioSound, + audioVolume, } = this._settingsFormValues; return html`
Settings
-
Preferences
+

Preferences

+

Exercises

-
Set Times (In Minutes)
+
+

Audio

+
+ + Preview sound +
+
+ +

Sound

+
+ +
+ +

Volume

+
+ +
+ +

Set Times (In Minutes)

`; } + #previewSound() { + const { audioSound, audioVolume } = this._settingsFormValues; + webAudioApiService.playSound(audioSound, audioVolume); + } + /** @param {Event} event */ #onSubmit(event) { event.preventDefault(); - for (const value of Object.values(this._settingsFormValues)) { + for (const [key, value] of Object.entries(this._settingsFormValues)) { if (!isNum(value) && !isBool(value)) { - alert(CLIENT_ERROR_MESSAGE.FORM.INVALID_INPUTS); + alert(CLIENT_ERROR_MESSAGE.INVALID_INPUTS); return; - } else if (isNum(value) && Number(value) <= 0) { - alert(CLIENT_ERROR_MESSAGE.FORM.INVALID_POSITIVE_INTEGER); + } else if (key !== 'audioVolume' && isNum(value) && Number(value) <= 0) { + alert(CLIENT_ERROR_MESSAGE.INVALID_POSITIVE_INTEGER); return; } } @@ -443,9 +518,22 @@ export class App extends LitElement { [name]: value, }; } + } else if (target instanceof HTMLSelectElement) { + const { name, value } = target; + + if (name === 'audioSound' || name === 'audioVolume') { + this._settingsFormValues = { + ...this._settingsFormValues, + [name]: Number(value), + }; + } } } + async #initAudio() { + await webAudioApiService.init(); + } + #closeEnableNotificationsModal() { this._enableNotificationsModalOpen = false; } @@ -467,6 +555,7 @@ export class App extends LitElement { captionTextStyles, modalStyles, linkStyles, + tooltipStyles, css` :host { display: flex; @@ -526,7 +615,9 @@ export class App extends LitElement { gap: 10px; } - #settingsForm h5 { + #settingsForm h2, + #settingsForm h3, + #settingsForm h4 { margin: 0; padding: 0; } @@ -553,6 +644,40 @@ export class App extends LitElement { #settingsForm .field-group input { max-width: 200px; } + + #settingsForm .select-group label { + width: 100%; + } + + #settingsForm .field-group select { + cursor: pointer; + width: 100%; + } + + #audioSection { + display: flex; + gap: 0.5rem; + align-items: flex-end; + } + + #previewSound { + border-radius: 50%; + border: 1px; + font-size: 0.7rem; + padding: 0.3rem 0.5rem; + } + + #previewSound:hover { + opacity: 0.8; + } + + #previewSound:disabled { + cursor: not-allowed !important; + } + + .tooltip-right .tooltiptext { + transform: translateY(-30%); + } `, ]; } diff --git a/src/assets/audio/alert-bells-echo.mp3 b/src/assets/audio/alert-bells-echo.mp3 new file mode 100644 index 0000000..9510630 Binary files /dev/null and b/src/assets/audio/alert-bells-echo.mp3 differ diff --git a/src/assets/audio/clear-announce-tones.mp3 b/src/assets/audio/clear-announce-tones.mp3 new file mode 100644 index 0000000..0be9429 Binary files /dev/null and b/src/assets/audio/clear-announce-tones.mp3 differ diff --git a/src/assets/audio/home-standard-ding-dong.mp3 b/src/assets/audio/home-standard-ding-dong.mp3 new file mode 100644 index 0000000..915f557 Binary files /dev/null and b/src/assets/audio/home-standard-ding-dong.mp3 differ diff --git a/src/assets/audio/melodic-classic-door-bell.mp3 b/src/assets/audio/melodic-classic-door-bell.mp3 new file mode 100644 index 0000000..9720d1e Binary files /dev/null and b/src/assets/audio/melodic-classic-door-bell.mp3 differ diff --git a/src/assets/audio/uplifting-flute-notification.mp3 b/src/assets/audio/uplifting-flute-notification.mp3 new file mode 100644 index 0000000..236acb9 Binary files /dev/null and b/src/assets/audio/uplifting-flute-notification.mp3 differ diff --git a/src/index.d.js b/src/index.d.js index 2b3970f..50a64b5 100644 --- a/src/index.d.js +++ b/src/index.d.js @@ -2,12 +2,20 @@ /** @typedef {"start" | "pause" | "reset"} PomodoroTimerAction */ -/** - * @typedef {Record} PomodoroMode - */ +/** @typedef {Record} PomodoroMode */ /** @typedef {"chrome" | "chromium" | "edge" | "firefox" | "opera" | "safari" | "seamonkey" | "unknown"} UserAgent */ +/** @typedef {"upperBody" | "lowerBody" | "core" | "cardio" | "mobility" | "balance" | "fullBody" | "staticStrength" | "yoga"} ExerciseCategory */ + +/** @typedef {[ExerciseCategory, readonly string[]]} ExerciseEntries */ + +/** + * @typedef {Record} Exercise + * @property {string} category + * @property {string} name + */ + /** * @typedef {object} Settings * @property {boolean} showTimerInTitle @@ -20,18 +28,8 @@ * @property {number} pomodoroMinutes * @property {number} shortBreakMinutes * @property {number} longBreakMinutes - */ - -/** @typedef {"upperBody" | "lowerBody" | "core" | "cardio" | "mobility" | "balance" | "fullBody" | "staticStrength" | "yoga"} ExerciseCategory */ - -/** - * @typedef {[ExerciseCategory, readonly string[]]} ExerciseEntries - */ - -/** - * @typedef {Record} Exercise - * @property {string} category - * @property {string} name + * @property {number} audioSound + * @property {number} audioVolume */ export {}; diff --git a/src/pages/home.js b/src/pages/home.js index fffba67..3620599 100644 --- a/src/pages/home.js +++ b/src/pages/home.js @@ -1,11 +1,13 @@ import { LitElement, css, html, nothing } from 'lit'; import { notificationApiService } from '../services/notification-api.service.js'; +import { webAudioApiService } from '../services/web-audio-api.service.js'; import { buttonStyles } from '../shared/styles/buttonStyles.js'; import { checkboxStyles } from '../shared/styles/checkboxStyles.js'; import ExercisesStore from '../stores/exercises.js'; import { DEFAULT_SETTINGS, settingsStore } from '../stores/settings.js'; import { + AUDIO_VOLUME, CLIENT_ERROR_MESSAGE, DEFAULT_POMODORO_TIMES, POMODORO_MODE, @@ -128,7 +130,7 @@ function getRandomMotivationalQuote() { ]; } -const POMODORO_MODES = Object.values(POMODORO_MODE); +const POMODORO_MODES = Object.freeze(Object.values(POMODORO_MODE)); export class HomePage extends LitElement { static properties = { @@ -316,7 +318,7 @@ export class HomePage extends LitElement { this.#start(); this.#dismissExercises(); } else { - console.warn(CLIENT_ERROR_MESSAGE.UNKNOWN_POMODORO_MODE); + console.error(CLIENT_ERROR_MESSAGE.UNKNOWN_POMODORO_MODE); } } } @@ -347,7 +349,7 @@ export class HomePage extends LitElement { this.#dismissExercises(); break; default: - console.warn(CLIENT_ERROR_MESSAGE.UNKNOWN_TIMER_ACTION); + console.error(CLIENT_ERROR_MESSAGE.UNKNOWN_TIMER_ACTION); } } } @@ -443,10 +445,20 @@ export class HomePage extends LitElement { }; #complete() { - const { enableNotifications, exercisesCount, showMotivationalQuote } = - this._settings; + const { + enableNotifications, + exercisesCount, + showMotivationalQuote, + audioSound, + audioVolume, + } = this._settings; const isPomodoroModeSelected = this._selectedPomodoroMode === POMODORO_MODE.POMODORO; + const isAudioVolumeMuteSelected = audioVolume === AUDIO_VOLUME.MUTE; + + if (!isAudioVolumeMuteSelected) { + webAudioApiService.playSound(audioSound, audioVolume); + } if (enableNotifications) { notificationApiService.sendDesktopNotification( diff --git a/src/services/web-audio-api.service.js b/src/services/web-audio-api.service.js new file mode 100644 index 0000000..9025e94 --- /dev/null +++ b/src/services/web-audio-api.service.js @@ -0,0 +1,108 @@ +import { + AUDIO_SOUND, + AUDIO_VOLUME, + CLIENT_ERROR_MESSAGE, +} from '../utils/constants.js'; + +class WebAudioApiService { + /** @type {AudioContext | undefined} */ + #audioContext; + /** @type {Record} */ + #audioFiles = {}; + /** @type {boolean} */ + #initialized = false; + /** @type {Map} */ + #buffersMap = new Map(); + + constructor() { + this.#audioFiles = import.meta.glob('../assets/audio/*.mp3', { + eager: true, + import: 'default', + }); + } + + get audioContext() { + return this.#audioContext; + } + + async init() { + if (!this.#audioContext) { + this.#audioContext = new AudioContext(); + } + + if (this.#audioContext.state === 'suspended') { + await this.#audioContext.resume(); + } + + if (this.#initialized) return; + this.#initialized = true; + + if (this.#buffersMap.size > 0) return; + + for (const [path, url] of Object.entries(this.#audioFiles)) { + const name = /** @type {string} */ ( + path?.split('/')?.pop()?.replace('.mp3', '') + ); + + try { + const response = await fetch(url); + const arrayBuffer = await response.arrayBuffer(); + const buffer = await this.#audioContext?.decodeAudioData(arrayBuffer); + this.#buffersMap.set(name, buffer); + } catch { + console.error(CLIENT_ERROR_MESSAGE.PRELOAD_AUDIO_FAILED); + } + } + } + + /** + * @param {number} volume + * @returns {GainNode} + */ + #createGain(volume = AUDIO_VOLUME.ONE_HUNDRED_PERCENT) { + const gainNode = /** @type {GainNode} */ (this.#audioContext?.createGain()); + gainNode.gain.value = Math.min(Math.max(volume / 100, 0), 1); + const destination = /** @type {AudioDestinationNode} */ ( + this.#audioContext?.destination + ); + gainNode.connect(destination); + return gainNode; + } + + /** + * @param {number} id + * @param {number} volume + * @returns {Promise} + */ + async playSound(id, volume = AUDIO_VOLUME.ONE_HUNDRED_PERCENT) { + if (!this.#audioContext) { + console.error( + `${CLIENT_ERROR_MESSAGE.PLAY_SOUND_FAILED} AudioContext unavailable.` + ); + return; + } + + const audioSounds = Object.values(AUDIO_SOUND); + const matchingAudioSound = audioSounds.find(({ ID }) => ID === id); + + if (!matchingAudioSound) { + // Fallback to default if sound not found + return this.playSound(audioSounds[0].ID, volume); + } + + try { + const source = /** @type {AudioBufferSourceNode} */ ( + this.#audioContext?.createBufferSource() + ); + source.buffer = /** @type {AudioBuffer} */ ( + this.#buffersMap.get(matchingAudioSound.NAME) + ); + source.connect(this.#createGain(volume)); + source.start(); + } catch { + console.error(CLIENT_ERROR_MESSAGE.PLAY_SOUND_FAILED); + } + } +} + +export const webAudioApiService = new WebAudioApiService(); diff --git a/src/shared/styles/modalStyles.js b/src/shared/styles/modalStyles.js index 37ad3ee..5490c7e 100644 --- a/src/shared/styles/modalStyles.js +++ b/src/shared/styles/modalStyles.js @@ -37,22 +37,39 @@ const modalStyles = css` .modal-content h1, .modal-content h2, - .modal-content h3 { - margin-top: 0; - font-size: 1.25rem; - font-weight: 600; - color: var(--primary, #222); - } - + .modal-content h3, .modal-content h4, .modal-content h5, .modal-content h6 { margin-top: 0; - font-size: 1rem; font-weight: 600; color: var(--primary, #222); } + .modal-content h1 { + font-size: 1.25rem; / + } + + .modal-content h2 { + font-size: 1.125rem; + } + + .modal-content h3 { + font-size: 1rem; + } + + .modal-content h4 { + font-size: 0.95rem; + } + + .modal-content h5 { + font-size: 0.875rem; + } + + .modal-content h6 { + font-size: 0.8rem; + } + .modal-content p { margin: 0.5rem 0 1rem; line-height: 1.5; @@ -92,6 +109,13 @@ const modalStyles = css` details:hover { cursor: pointer; } + + @media (max-width: 640px) { + .modal { + padding: 0.75rem; + } +} + `; export { modalStyles }; diff --git a/src/stores/settings.js b/src/stores/settings.js index 5295502..306a20a 100644 --- a/src/stores/settings.js +++ b/src/stores/settings.js @@ -1,5 +1,7 @@ import LocalStorageService from '../services/local-storage.service.js'; import { + AUDIO_SOUND, + AUDIO_VOLUME, CLIENT_ERROR_MESSAGE, DEFAULT_POMODORO_TIMES, STORAGE_KEY_NAMESPACE, @@ -15,6 +17,8 @@ export const DEFAULT_SETTINGS = Object.freeze({ enableNotifications: false, showTimerInTitle: false, showMotivationalQuote: true, + audioSound: AUDIO_SOUND.MELODIC_CLASSIC_DOOR_BELL.ID, + audioVolume: AUDIO_VOLUME.FIFTY_PERCENT, ...DEFAULT_POMODORO_TIMES, }); @@ -32,7 +36,7 @@ class SettingsStore extends EventTarget { } this.#settingsStorage = settingsStorage; - // Load settings from storage on init + // Load settings from storage const settingsMap = new Map(Object.entries(this.#settings)); for (const [key, defaultValue] of settingsMap.entries()) { @@ -40,7 +44,7 @@ class SettingsStore extends EventTarget { this.#settingsStorage.get(key) ); - const value = /** @type {boolean | number} */ ( + let value = /** @type {boolean | number} */ ( storedValue === null ? defaultValue : (isBool(defaultValue) && isBool(storedValue)) || @@ -49,6 +53,22 @@ class SettingsStore extends EventTarget { : defaultValue ); + if (key === 'audioSound' && typeof value === 'number') { + const matchingAudioSound = Object.values(AUDIO_SOUND).find( + ({ ID }) => ID === value + ); + if (matchingAudioSound === undefined) { + value = defaultValue; + } + } else if (key === 'audioVolume' && typeof value === 'number') { + const matchingAudioVolume = Object.values(AUDIO_VOLUME).find( + (volume) => volume === value + ); + if (matchingAudioVolume === undefined) { + value = defaultValue; + } + } + settingsMap.set(key, value); this.#settingsStorage.set(key, value); } diff --git a/src/utils/constants.js b/src/utils/constants.js index 6fe4d81..ce4c76c 100644 --- a/src/utils/constants.js +++ b/src/utils/constants.js @@ -3,6 +3,31 @@ const APP_EVENT = Object.freeze({ SETTINGS_MODAL: 'settings-modal', }); +const AUDIO_SOUND = Object.freeze({ + ALERT_BELLS_ECHO: Object.freeze({ ID: 1, NAME: 'alert-bells-echo' }), + CLEAR_ANNOUNCE_TONES: Object.freeze({ ID: 2, NAME: 'clear-announce-tones' }), + HOME_STANDARD_DING_DONG: Object.freeze({ + ID: 3, + NAME: 'home-standard-ding-dong', + }), + MELODIC_CLASSIC_DOOR_BELL: Object.freeze({ + ID: 4, + NAME: 'melodic-classic-door-bell', + }), + UPLIFTING_FLUTE_NOTIFICATION: Object.freeze({ + ID: 5, + NAME: 'uplifting-flute-notification', + }), +}); + +const AUDIO_VOLUME = Object.freeze({ + MUTE: 0, + TWENTY_FIVE_PERCENT: 25, + FIFTY_PERCENT: 50, + SEVENTY_FIVE_PERCENT: 75, + ONE_HUNDRED_PERCENT: 100, +}); + const CLIENT_ERROR_MESSAGE = Object.freeze({ STORAGE_INVALID: 'Storage invalid.', STORAGE_NAMESPACE_INVALID: 'Storage namespace invalid.', @@ -13,10 +38,10 @@ const CLIENT_ERROR_MESSAGE = Object.freeze({ NOTIFICATION_REQUEST_PERMISSION_FAILED: 'Error requesting notification permission.', INVALID_INPUT: 'Invalid input.', - FORM: Object.freeze({ - INVALID_INPUTS: 'Input(s) invalid. Please try again.', - INVALID_POSITIVE_INTEGER: 'Please use positive integers.', - }), + INVALID_INPUTS: 'Input(s) invalid. Please try again.', + INVALID_POSITIVE_INTEGER: 'Please use positive integers.', + PLAY_SOUND_FAILED: 'Audio playback failed.', + PRELOAD_AUDIO_FAILED: 'Failed to load audio/sound.', }); const POMODORO_MODE = Object.freeze({ @@ -55,6 +80,8 @@ const NOTIFICATION_PERMISSION = Object.freeze({ export { APP_EVENT, + AUDIO_SOUND, + AUDIO_VOLUME, CLIENT_ERROR_MESSAGE, DEFAULT_POMODORO_TIMES, POMODORO_MODE, diff --git a/vite.config.js b/vite.config.js index 66157c3..6e99242 100644 --- a/vite.config.js +++ b/vite.config.js @@ -27,7 +27,7 @@ export default defineConfig(({ mode }) => { { name: 'csp', transformIndexHtml(html) { - const csp = `default-src 'none'; script-src 'self'; connect-src 'self'; img-src 'self'; style-src 'self'; form-action 'self'; object-src 'none'; media-src 'none'; base-uri 'none'; upgrade-insecure-requests;`; + const csp = `default-src 'none'; script-src 'self'; connect-src 'self'; img-src 'self'; style-src 'self'; form-action 'self'; object-src 'none'; media-src 'self'; base-uri 'none'; upgrade-insecure-requests;`; html = html.replace( //, `\n`