import { text } from './utils/fetch.mjs'; import Setting from './utils/setting.mjs'; import { registerHiddenSetting } from './share.mjs'; import { withBasePath } from './utils/base-path.mjs'; let playlist; let currentTrack = 0; let player; let alertTonePlayer; let sliderTimeout = null; let volumeSlider = null; let volumeSliderInput = null; let alertToneActive = false; let alertTonePending = false; let audioUnlocked = false; const ALERT_DUCK_VOLUME = 0.05; const MAX_MEDIA_VOLUME = 0.20; const isAlertToneBlockingMedia = () => alertToneActive || alertTonePending; const clampMediaVolume = (value) => Math.min(Math.max(value, ALERT_DUCK_VOLUME), MAX_MEDIA_VOLUME); const getActiveMediaVolume = () => { if (isAlertToneBlockingMedia()) { return ALERT_DUCK_VOLUME; } return clampMediaVolume(mediaVolume.value); }; const mediaPlaying = new Setting('mediaPlaying', { name: 'Media Playing', type: 'boolean', defaultValue: false, sticky: true, }); document.addEventListener('DOMContentLoaded', () => { // add the event handler to the page document.getElementById('ToggleMedia').addEventListener('click', handleClick); // get the slider elements volumeSlider = document.querySelector('#ToggleMediaContainer .volume-slider'); volumeSliderInput = volumeSlider.querySelector('input'); // catch interactions with the volume slider (timeout handler) // called on any interaction via 'input' (vs change) for immediate volume response volumeSlider.addEventListener('input', setSliderTimeout); volumeSlider.addEventListener('input', sliderChanged); // add listener for mute (pause) button under the volume slider volumeSlider.querySelector('img').addEventListener('click', stopMedia); // get the playlist getMedia(); registerAudioUnlockHandlers(); // register the volume setting registerHiddenSetting(mediaVolume.elemId, mediaVolume); if (mediaVolume.value !== clampMediaVolume(mediaVolume.value)) { mediaVolume.value = clampMediaVolume(mediaVolume.value); } // Screen audio setting is managed via localStorage and checked directly in playScreenAudio() }); const unlockAudio = () => { if (audioUnlocked) return; audioUnlocked = true; if (alertTonePending) { startAlertTone(); } }; const registerAudioUnlockHandlers = () => { ['pointerdown', 'keydown', 'touchstart'].forEach((eventName) => { document.addEventListener(eventName, unlockAudio, { passive: true, once: true }); }); }; const scanMusicDirectory = async () => { const parseDirectory = async (path, prefix = '') => { const listing = await text(path); const matches = [...listing.matchAll(/href="([^"]+\.mp3)"/gi)]; return matches.map((m) => `${prefix}${m[1]}`); }; try { let files = await parseDirectory('music/'); if (files.length === 0) { files = await parseDirectory('music/default/', 'default/'); } return { availableFiles: files }; } catch (e) { console.error('Unable to scan music directory'); console.error(e); return { availableFiles: [] }; } }; const getMedia = async () => { let playlistSource = ''; try { const response = await fetch('playlist.json'); if (response.ok) { playlist = await response.json(); playlistSource = 'from server'; } else if (response.status === 404 && response.headers.get('X-Weatherstar') === 'true') { // Expected behavior in static deployment mode playlist = await scanMusicDirectory(); playlistSource = 'via directory scan (static deployment)'; } else { playlist = { availableFiles: [] }; playlistSource = `failed (${response.status} ${response.statusText})`; } } catch (_e) { // Network error or other fetch failure - fall back to directory scanning playlist = await scanMusicDirectory(); playlistSource = 'via directory scan (after fetch failed)'; } const fileCount = playlist?.availableFiles?.length || 0; if (fileCount > 0) { console.log(`Loaded playlist ${playlistSource} - found ${fileCount} music file${fileCount === 1 ? '' : 's'}`); } else { console.log(`No music files found ${playlistSource}`); } enableMediaPlayer(); }; const enableMediaPlayer = () => { // see if files are available if (playlist?.availableFiles?.length > 0) { // randomize the list randomizePlaylist(); // enable the icon const icon = document.getElementById('ToggleMediaContainer'); icon.classList.add('available'); // set the button type setIcon(); // if we're already playing (sticky option) then try to start playing if (mediaPlaying.value === true) { startMedia(); } } }; const setIcon = () => { // get the icon const icon = document.getElementById('ToggleMediaContainer'); if (mediaPlaying.value === true) { icon.classList.add('playing'); } else { icon.classList.remove('playing'); } }; const handleClick = () => { // if media is off, start it if (mediaPlaying.value === false) { mediaPlaying.value = true; } if (mediaPlaying.value === true && !volumeSlider.classList.contains('show')) { // if media is playing and the slider isn't open, open it showVolumeSlider(); } else { // hide the volume slider hideVolumeSlider(); } // handle the state change stateChanged(); }; // set a timeout for the volume slider (called by interactions with the slider) const setSliderTimeout = () => { // clear existing timeout if (sliderTimeout) clearTimeout(sliderTimeout); // set a new timeout sliderTimeout = setTimeout(hideVolumeSlider, 5000); }; // show the volume slider and configure a timeout const showVolumeSlider = () => { setSliderTimeout(); // show the slider if (volumeSlider) { volumeSlider.classList.add('show'); } }; // hide the volume slider and clean up the timeout const hideVolumeSlider = () => { // clear the timeout handler if (sliderTimeout) clearTimeout(sliderTimeout); sliderTimeout = null; // hide the element if (volumeSlider) { volumeSlider.classList.remove('show'); } }; const startMedia = async () => { // if there's not media player yet, enable it if (!player) { initializePlayer(); } else { try { player.volume = getActiveMediaVolume(); await player.play(); setTrackName(playlist.availableFiles[currentTrack]); } catch (e) { // report the error console.error('Couldn\'t play music'); console.error(e); // set state back to not playing for good UI experience mediaPlaying.value = false; stateChanged(); setTrackName('Not playing'); } } }; const stopMedia = () => { hideVolumeSlider(); if (!player) return; player.pause(); mediaPlaying.value = false; setTrackName('Not playing'); setIcon(); }; const stateChanged = () => { // update the icon setIcon(); // react to the new state if (mediaPlaying.value) { startMedia(); } else { stopMedia(); } }; const randomizePlaylist = () => { let availableFiles = [...playlist.availableFiles]; const randomPlaylist = []; while (availableFiles.length > 0) { // get a randon item from the available files const i = Math.floor(Math.random() * availableFiles.length); // add it to the final list randomPlaylist.push(availableFiles[i]); // remove the file from the available files availableFiles = availableFiles.filter((file, index) => index !== i); } playlist.availableFiles = randomPlaylist; }; const setVolume = (newVolume) => { const clampedVolume = clampMediaVolume(newVolume); if (player) { player.volume = isAlertToneBlockingMedia() ? ALERT_DUCK_VOLUME : clampedVolume; } }; const sliderChanged = () => { // get the value of the slider if (volumeSlider) { const newValue = volumeSliderInput.value; const cleanValue = clampMediaVolume(parseFloat(newValue) / 100); mediaVolume.value = cleanValue; } }; const mediaVolume = new Setting('mediaVolume', { name: 'Volume', type: 'select', defaultValue: 0.15, values: [ [0.20, '20%'], [0.15, '15%'], [0.10, '10%'], [0.05, '5%'], ], changeAction: setVolume, }); const initializePlayer = () => { // basic sanity checks if (!playlist.availableFiles || playlist?.availableFiles.length === 0) { throw new Error('No playlist available'); } if (player) { return; } // create the player player = new Audio(); // reset the playlist index currentTrack = 0; // add event handlers player.addEventListener('canplay', playerCanPlay); player.addEventListener('ended', playerEnded); // get the first file player.src = `music/${playlist.availableFiles[currentTrack]}`; setTrackName(playlist.availableFiles[currentTrack]); player.type = 'audio/mpeg'; // set volume and slider indicator setVolume(getActiveMediaVolume()); volumeSliderInput.value = Math.round(mediaVolume.value * 100); }; const initializeAlertTonePlayer = () => { if (alertTonePlayer) return; alertTonePlayer = new Audio(withBasePath('alert/tone.mp3')); alertTonePlayer.type = 'audio/mpeg'; alertTonePlayer.preload = 'auto'; alertTonePlayer.addEventListener('ended', finishAlertTone); }; const duckMediaForAlert = () => { if (!player || player.paused) return; player.volume = ALERT_DUCK_VOLUME; }; const restoreMediaAfterAlert = () => { if (!player) return; player.volume = clampMediaVolume(mediaVolume.value); }; const finishAlertTone = () => { alertToneActive = false; alertTonePending = false; restoreMediaAfterAlert(); }; const startAlertTone = async () => { if (!audioUnlocked) { alertTonePending = true; return; } stopScreenAudio(); // Stop screen audio when alert plays initializeAlertTonePlayer(); try { alertTonePending = true; duckMediaForAlert(); alertToneActive = true; alertTonePlayer.currentTime = 0; await alertTonePlayer.play(); alertTonePending = false; } catch (e) { alertToneActive = false; alertTonePending = false; restoreMediaAfterAlert(); console.error('Couldn\'t play alert tone'); console.error(e); } }; const stopAlertTone = () => { alertTonePending = false; if (alertTonePlayer) { alertTonePlayer.pause(); alertTonePlayer.currentTime = 0; } finishAlertTone(); }; const playAlertTone = () => startAlertTone(); // Screen audio constants and variables const SCREEN_AUDIO_DUCK_VOLUME = 0.10; // 10% ducking (vs 5% for alerts) const screenAudioMap = { 'radar': 'local-radar.mp3', 'regional-forecast': 'regional-observations.mp3', 'travel': 'travel-forecast.mp3', 'hourly-graph': 'hourly-graph.mp3', 'hourly': 'hourly-forecast.mp3', 'server-observations': 'server-obs.mp3', 'current-weather': 'current-conditions.mp3', }; let screenAudioPlayer = null; let audioStartTime = 0; let audioStopTimeout = null; const AUDIO_MIN_PLAY_TIME = 500; // 500ms minimum play time let currentScreenId = null; // Helper function to check if screen audio is enabled (always reads from localStorage) const isScreenAudioEnabled = () => { const saved = window.localStorage.getItem('screenAudioEnabled'); return saved !== null ? saved === 'true' : true; // Default: enabled }; // Play screen audio const playScreenAudio = async (screenId) => { console.log(`[AUDIO] playScreenAudio called for ${screenId}`); // Always check localStorage to ensure setting is current if (!isScreenAudioEnabled()) { console.log(`[AUDIO] Aborting - screen audio disabled`); return; } const fileName = screenAudioMap[screenId]; if (!fileName) { console.log(`[AUDIO] No audio file mapped for ${screenId}`); return; } // Cancel any pending stop timeout from previous audio if (audioStopTimeout) { console.log(`[AUDIO] Cancelling pending stop timeout for new audio`); clearTimeout(audioStopTimeout); audioStopTimeout = null; } // Stop any existing screen audio first stopScreenAudio(); // Don't play if alert tone is active if (alertToneActive || alertTonePending) { console.log(`[AUDIO] Aborting - alert tone active/pending`); return; } // Duck background music to 10% if (player && !player.paused) { player.volume = SCREEN_AUDIO_DUCK_VOLUME; } // Create and play audio screenAudioPlayer = new Audio(withBasePath(`alert/${fileName}`)); screenAudioPlayer.type = 'audio/mpeg'; currentScreenId = screenId; screenAudioPlayer.addEventListener('ended', () => { console.log(`[AUDIO] 'ended' event fired for ${currentScreenId}`); screenAudioPlayer = null; currentScreenId = null; // Only restore if alert isn't playing if (!alertToneActive && !alertTonePending) { restoreMediaAfterAlert(); } }); screenAudioPlayer.addEventListener('error', (e) => { console.log(`[AUDIO] 'error' event fired for ${currentScreenId}:`, e.message || e); screenAudioPlayer = null; currentScreenId = null; if (!alertToneActive && !alertTonePending) { restoreMediaAfterAlert(); } }); try { await screenAudioPlayer.play(); audioStartTime = Date.now(); console.log(`[AUDIO] Audio started for ${screenId} at ${audioStartTime}`); } catch (e) { console.log(`[AUDIO] Failed to play for ${screenId}:`, e.message); screenAudioPlayer = null; currentScreenId = null; if (!alertToneActive && !alertTonePending) { restoreMediaAfterAlert(); } } }; // Actually stop the audio (internal helper) const actuallyStopAudio = () => { console.log(`[AUDIO] Actually stopping audio for ${currentScreenId}`); if (screenAudioPlayer) { screenAudioPlayer.pause(); screenAudioPlayer.currentTime = 0; screenAudioPlayer = null; } currentScreenId = null; audioStartTime = 0; audioStopTimeout = null; }; // Stop screen audio (with minimum play time protection) const stopScreenAudio = () => { if (!screenAudioPlayer) { console.log(`[AUDIO] stopScreenAudio called but no audio playing`); return; } const elapsed = Date.now() - audioStartTime; console.log(`[AUDIO] stopScreenAudio called for ${currentScreenId}, elapsed: ${elapsed}ms`); // If we haven't played for the minimum time, delay the stop if (elapsed < AUDIO_MIN_PLAY_TIME) { const delay = AUDIO_MIN_PLAY_TIME - elapsed; console.log(`[AUDIO] Delaying stop for ${delay}ms (minimum play time: ${AUDIO_MIN_PLAY_TIME}ms)`); // Clear any existing timeout first if (audioStopTimeout) { clearTimeout(audioStopTimeout); } audioStopTimeout = setTimeout(() => { // Check if this audio is still the current one (might have been replaced) if (screenAudioPlayer && Date.now() - audioStartTime >= AUDIO_MIN_PLAY_TIME) { console.log(`[AUDIO] Executing delayed stop for ${currentScreenId}`); actuallyStopAudio(); // Restore music volume after delayed stop if (!alertToneActive && !alertTonePending) { restoreMediaAfterAlert(); } } else { console.log(`[AUDIO] Delayed stop cancelled - audio already changed or stopped`); } }, delay); } else { // Minimum play time met, stop immediately console.log(`[AUDIO] Minimum play time met, stopping immediately`); actuallyStopAudio(); } }; const playerCanPlay = async () => { // check to make sure they user still wants music (protect against slow loading music) if (!mediaPlaying.value) return; // start playing startMedia(); }; const playerEnded = () => { // next track currentTrack += 1; // roll over and re-randomize the tracks if (currentTrack >= playlist.availableFiles.length) { randomizePlaylist(); currentTrack = 0; } // update the player source player.src = `music/${playlist.availableFiles[currentTrack]}`; setTrackName(playlist.availableFiles[currentTrack]); }; const setTrackName = (fileName) => { const baseName = fileName.split('/').pop(); const trackName = decodeURIComponent( baseName.replace(/\.mp3/gi, '').replace(/(_-)/gi, ''), ); document.getElementById('musicTrack').innerHTML = trackName; }; export { handleClick, playAlertTone, stopAlertTone, playScreenAudio, stopScreenAudio, };