562 lines
15 KiB
JavaScript
562 lines
15 KiB
JavaScript
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,
|
|
};
|