add volume control slider and overhaul settings close #109

This commit is contained in:
Matt Walsh
2025-11-10 12:54:54 -06:00
parent 8255efd3f7
commit 6f97e3d2b9
12 changed files with 225 additions and 56 deletions

View File

@@ -4,11 +4,12 @@ import {
message as navMessage, isPlaying, resize, resetStatuses, latLonReceived, isIOS,
} from './modules/navigation.mjs';
import { round2 } from './modules/utils/units.mjs';
import { parseQueryString } from './modules/share.mjs';
import { registerHiddenSetting } from './modules/share.mjs';
import settings from './modules/settings.mjs';
import AutoComplete from './modules/autocomplete.mjs';
import { loadAllData } from './modules/utils/data-loader.mjs';
import { debugFlag } from './modules/utils/debug.mjs';
import { parseQueryString } from './modules/utils/setting.mjs';
document.addEventListener('DOMContentLoaded', () => {
init();
@@ -131,7 +132,7 @@ const init = async () => {
const { lat, lon } = JSON.parse(latLon);
getForecastFromLatLon(lat, lon, true);
} else {
// otherwise use pre-stored data
// otherwise use pre-stored data
loadData(JSON.parse(latLon));
}
}
@@ -177,6 +178,10 @@ const init = async () => {
// swipe functionality
document.querySelector('#container').addEventListener('swiped-left', () => swipeCallBack('left'));
document.querySelector('#container').addEventListener('swiped-right', () => swipeCallBack('right'));
// register hidden settings for search and location query
registerHiddenSetting('latLonQuery', () => localStorage.getItem('latLonQuery'));
registerHiddenSetting('latLon', () => localStorage.getItem('latLon'));
};
const geocodeLatLonQuery = async (query) => {

View File

@@ -1,9 +1,13 @@
import { text } from './utils/fetch.mjs';
import Setting from './utils/setting.mjs';
import { registerHiddenSetting } from './share.mjs';
let playlist;
let currentTrack = 0;
let player;
let sliderTimeout = null;
let volumeSlider = null;
let volumeSliderInput = null;
const mediaPlaying = new Setting('mediaPlaying', {
name: 'Media Playing',
@@ -14,9 +18,24 @@ const mediaPlaying = new Setting('mediaPlaying', {
document.addEventListener('DOMContentLoaded', () => {
// add the event handler to the page
document.getElementById('ToggleMedia').addEventListener('click', toggleMedia);
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();
// register the volume setting
registerHiddenSetting(mediaVolume.elemId, mediaVolume);
});
const scanMusicDirectory = async () => {
@@ -77,7 +96,7 @@ const enableMediaPlayer = () => {
// randomize the list
randomizePlaylist();
// enable the icon
const icon = document.getElementById('ToggleMedia');
const icon = document.getElementById('ToggleMediaContainer');
icon.classList.add('available');
// set the button type
setIcon();
@@ -85,15 +104,12 @@ const enableMediaPlayer = () => {
if (mediaPlaying.value === true) {
startMedia();
}
// add the volume control to the page
const settingsSection = document.querySelector('#settings');
settingsSection.append(mediaVolume.generate());
}
};
const setIcon = () => {
// get the icon
const icon = document.getElementById('ToggleMedia');
const icon = document.getElementById('ToggleMediaContainer');
if (mediaPlaying.value === true) {
icon.classList.add('playing');
} else {
@@ -101,18 +117,54 @@ const setIcon = () => {
}
};
const toggleMedia = (forcedState) => {
// handle forcing
if (typeof forcedState === 'boolean') {
mediaPlaying.value = forcedState;
} else {
// toggle the state
mediaPlaying.value = !mediaPlaying.value;
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) {
@@ -134,9 +186,12 @@ const startMedia = async () => {
};
const stopMedia = () => {
hideVolumeSlider();
if (!player) return;
player.pause();
mediaPlaying.value = false;
setTrackName('Not playing');
setIcon();
};
const stateChanged = () => {
@@ -170,6 +225,16 @@ const setVolume = (newVolume) => {
}
};
const sliderChanged = () => {
// get the value of the slider
if (volumeSlider) {
const newValue = volumeSliderInput.value;
const cleanValue = parseFloat(newValue) / 100;
setVolume(cleanValue);
mediaVolume.value = cleanValue;
}
};
const mediaVolume = new Setting('mediaVolume', {
name: 'Volume',
type: 'select',
@@ -205,7 +270,9 @@ const initializePlayer = () => {
player.src = `music/${playlist.availableFiles[currentTrack]}`;
setTrackName(playlist.availableFiles[currentTrack]);
player.type = 'audio/mpeg';
// set volume and slider indicator
setVolume(mediaVolume.value);
volumeSliderInput.value = Math.round(mediaVolume.value * 100);
};
const playerCanPlay = async () => {
@@ -238,5 +305,5 @@ const setTrackName = (fileName) => {
export {
// eslint-disable-next-line import/prefer-default-export
toggleMedia,
handleClick,
};

View File

@@ -1,4 +1,5 @@
import Setting from './utils/setting.mjs';
import { registerHiddenSetting } from './share.mjs';
// Initialize settings immediately so other modules can access them
const settings = { speed: { value: 1.0 } };
@@ -6,6 +7,11 @@ const settings = { speed: { value: 1.0 } };
// Track settings that need DOM changes after early initialization
const deferredDomSettings = new Set();
// don't show checkboxes for these settings
const hiddenSettings = [
'scanLines',
];
// Declare change functions first, before they're referenced in init() to avoid the Temporal Dead Zone (TDZ)
const wideScreenChange = (value) => {
const container = document.querySelector('#divTwc');
@@ -63,13 +69,19 @@ const scanLineChange = (value) => {
return;
}
const modeSelect = document.getElementById('settings-scanLineMode-label');
if (value) {
container.classList.add('scanlines');
navIcons.classList.add('on');
modeSelect?.style?.removeProperty('display');
} else {
// Remove all scanline classes
container.classList.remove('scanlines', 'scanlines-auto', 'scanlines-fine', 'scanlines-normal', 'scanlines-thick', 'scanlines-classic', 'scanlines-retro');
navIcons.classList.remove('on');
if (modeSelect) {
modeSelect.style.display = 'none';
}
}
};
@@ -206,10 +218,28 @@ document.addEventListener('DOMContentLoaded', () => {
}
// Then generate the settings UI
const settingHtml = Object.values(settings).map((d) => d.generate());
const settingHtml = Object.values(settings).map((setting) => {
if (hiddenSettings.includes(setting.shortName)) {
// setting is hidden, register it
registerHiddenSetting(setting.elemId, setting);
return false;
}
// generate HTML for setting
return setting.generate();
}).filter((d) => d);
const settingsSection = document.querySelector('#settings');
settingsSection.innerHTML = '';
settingsSection.append(...settingHtml);
// update visibility on some settings
const modeSelect = document.getElementById('settings-scanLineMode-label');
const { value } = settings.scanLines;
if (value) {
modeSelect?.style?.removeProperty('display');
} else if (modeSelect) {
modeSelect.style.display = 'none';
}
registerHiddenSetting('settings-scanLineMode-select', settings.scanLineMode);
});
export default settings;

View File

@@ -1,11 +1,10 @@
import { elemForEach } from './utils/elem.mjs';
import Setting from './utils/setting.mjs';
document.addEventListener('DOMContentLoaded', () => init());
// shorthand mappings for frequently used values
const specialMappings = {
kiosk: 'settings-kiosk-checkbox',
};
// array of settings that are not checkboxes or dropdowns (i.e. volume slider)
const hiddenSettings = [];
const init = () => {
// add action to existing link
@@ -45,9 +44,15 @@ const createLink = async (e) => {
}
}));
// add the location string
queryStringElements.latLonQuery = localStorage.getItem('latLonQuery');
queryStringElements.latLon = localStorage.getItem('latLon');
// get any hidden settings
hiddenSettings.forEach((setting) => {
// determine type
if (setting.value instanceof Setting) {
queryStringElements[setting.name] = setting.value.value;
} else if (typeof setting.value === 'function') {
queryStringElements[setting.name] = setting.value();
}
});
const queryString = (new URLSearchParams(queryStringElements)).toString();
@@ -90,29 +95,17 @@ const writeLinkToPage = (url) => {
shareLinkUrl.select();
};
const parseQueryString = () => {
// return memoized result
if (parseQueryString.params) return parseQueryString.params;
const urlSearchParams = new URLSearchParams(window.location.search);
// turn into an array of key-value pairs
const paramsArray = [...urlSearchParams];
// add additional expanded keys
paramsArray.forEach((paramPair) => {
const expandedKey = specialMappings[paramPair[0]];
if (expandedKey) {
paramsArray.push([expandedKey, paramPair[1]]);
}
const registerHiddenSetting = (name, value) => {
// name is the id of the element
// value can be a function that returns the current value of the setting
// or an instance of Setting
hiddenSettings.push({
name,
value,
});
// memoize result
parseQueryString.params = Object.fromEntries(paramsArray);
return parseQueryString.params;
};
export {
createLink,
parseQueryString,
registerHiddenSetting,
};

View File

@@ -1,5 +1,3 @@
import { parseQueryString } from '../share.mjs';
const SETTINGS_KEY = 'Settings';
const DEFAULTS = {
@@ -15,6 +13,11 @@ const DEFAULTS = {
placeholder: '',
};
// shorthand mappings for frequently used values
const specialMappings = {
kiosk: 'settings-kiosk-checkbox',
};
class Setting {
constructor(shortName, _options) {
if (shortName === undefined) {
@@ -35,9 +38,10 @@ class Setting {
this.visible = options.visible;
this.changeAction = options.changeAction;
this.placeholder = options.placeholder;
this.elemId = `settings-${shortName}-${this.type}`;
// get value from url
const urlValue = parseQueryString()?.[`settings-${shortName}-${this.type}`];
const urlValue = parseQueryString()?.[this.elemId];
let urlState;
if (this.type === 'checkbox' && urlValue !== undefined) {
urlState = urlValue === 'true';
@@ -254,7 +258,10 @@ class Setting {
break;
case 'checkbox':
default:
this.element.querySelector('input').checked = newValue;
// allow for a hidden checkbox (typically items in the player control bar)
if (this.element) {
this.element.querySelector('input').checked = newValue;
}
}
this.storeToLocalStorage(this.myValue);
@@ -285,4 +292,30 @@ class Setting {
}
}
const parseQueryString = () => {
// return memoized result
if (parseQueryString.params) return parseQueryString.params;
const urlSearchParams = new URLSearchParams(window.location.search);
// turn into an array of key-value pairs
const paramsArray = [...urlSearchParams];
// add additional expanded keys
paramsArray.forEach((paramPair) => {
const expandedKey = specialMappings[paramPair[0]];
if (expandedKey) {
paramsArray.push([expandedKey, paramPair[1]]);
}
});
// memoize result
parseQueryString.params = Object.fromEntries(paramsArray);
return parseQueryString.params;
};
export default Setting;
export {
parseQueryString,
};

View File

@@ -5,7 +5,7 @@ import { DateTime } from '../vendor/auto/luxon.mjs';
import {
msg, displayNavMessage, isPlaying, updateStatus, timeZone,
} from './navigation.mjs';
import { parseQueryString } from './share.mjs';
import { parseQueryString } from './utils/setting.mjs';
import settings from './settings.mjs';
import { elemForEach } from './utils/elem.mjs';
import { debugFlag } from './utils/debug.mjs';

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -2,8 +2,9 @@
display: none;
}
#ToggleMedia {
#ToggleMediaContainer {
display: none;
position: relative;
&.available {
display: inline-block;
@@ -31,4 +32,31 @@
}
.volume-slider {
display: none;
position: absolute;
top: 0px;
transform: translateY(-100%);
width: 100%;
background-color: #000;
text-align: center;
@media (prefers-color-scheme: dark) {
background-color: #303030;
}
input[type="range"] {
writing-mode: vertical-lr;
direction: rtl;
margin-top: 20px;
margin-bottom: 20px;
}
&.show {
display: block;
}
}
}

View File

@@ -815,4 +815,10 @@ body.kiosk #loading .instructions {
>*:not(#divTwc) {
display: none !important;
}
}
#divInfo {
display: grid;
grid-template-columns: 1fr 1fr;
max-width: 250px;
}

View File

@@ -147,9 +147,15 @@
<img id="NavigateRefresh" class="navButton" src="images/nav/ic_refresh_white_24dp_2x.png" title="Refresh" />
</div>
<div id="divTwcBottomRight">
<div id="ToggleMedia">
<img class="navButton off" src="images/nav/ic_volume_off_white_24dp_2x.png" title="Unmute" />
<img class="navButton on" src="images/nav/ic_volume_on_white_24dp_2x.png" title="Mute" />
<div id="ToggleMediaContainer">
<div id="ToggleMedia">
<img class="navButton off" src="images/nav/ic_volume_off_white_24dp_2x.png" title="Unmute" />
<img class="navButton on" src="images/nav/ic_volume_on_white_24dp_2x.png" title="Volume" />
</div>
<div class="volume-slider">
<input type="range" min="1" max="100" value="75" /><br>
<img class="navButton" src="images/nav/ic_volume_off_white_24dp_2x.png" title="Mute" />
</div>
</div>
<div id="ToggleScanlines">
<img class="navButton off" src="images/nav/ic_scanlines_off_white_24dp_2x.png" title="Scan lines on" />

View File

@@ -45,7 +45,8 @@
"unmuted",
"dumpio",
"mesonet",
"metar"
"metar",
"Unmute"
],
"cSpell.ignorePaths": [
"**/package-lock.json",