/*! * ************************************************ * FM-DX Webserver WHEP Audio Plugin * ************************************************ * Created by techkrzysiek * Stolen by me (thanks man) * ************************************************ */ window.addEventListener('load', (() => { const WHEP_CONFIG_PATH = '/webrtc-audio.conf'; const WHEP_FLAGS_PATH = '/webrtc-audio-flags.json'; const LAST_BITRATE_COOKIE_NAME = 'webrtcAudioLastBitrate'; const LAST_SOURCE_COOKIE_NAME = 'webrtcAudioSource'; const LEGACY_SOURCE_VALUE = '3las'; const SELECT_ID = 'webrtc-audio-mode-select'; const SELECT_LOADING_VALUE = 'webrtc:loading'; const MANAGED_PLAYBUTTON_CLASS = 'fmdx-webrtc-playbutton'; const LEGACY_PROXY_BUTTON_ID = 'fmdx-webrtc-legacy-playbutton'; const SELECT_TOOLTIP_HTML = 'WebRTC - very low latency audio.'; const DEBUG_WEBRTC_AUDIO = false; const RECONNECT_DELAY = 3000; const FIRST_DISCONNECT_RECONNECT_DELAY = 500; const MAX_RECONNECT_ATTEMPTS = 50; const UI_ATTACH_INITIAL_DELAY = 100; const ONDEMAND_STARTUP_404_GRACE_ATTEMPTS = 5; const ONDEMAND_STARTUP_RECONNECT_MS_FAST = 500; const ONDEMAND_STARTUP_RECONNECT_MS_SLOW = 750; const ONDEMAND_STARTUP_FAST_RETRY_COUNT = 2; const webrtcCss = ` .fmdx-webrtc-playbutton-host:not(#mobileTray) { width: 92px !important; overflow: visible; } .fmdx-webrtc-button-stack { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px; height: 100%; width: 100%; } .fmdx-webrtc-playbutton-host .${MANAGED_PLAYBUTTON_CLASS} { min-height: 64px; height: 64px; } #mobileTray .fmdx-webrtc-button-stack { position: absolute; top: -50%; left: 50%; margin-left: -32px; width: 64px !important; height: 64px; display: block; } #mobileTray .${MANAGED_PLAYBUTTON_CLASS} { width: 64px !important; height: 64px !important; display: block !important; box-sizing: border-box !important; position: static !important; transform: none !important; } #mobileTray .fmdx-webrtc-mode-select { position: absolute; bottom: calc(100% + 6px); left: 50%; transform: translateX(-50%); width: max-content; height: 24px; padding: 1px 4px; font-size: 11px; } .${MANAGED_PLAYBUTTON_CLASS} i { display: inline-block; width: 1em; text-align: center; } #${LEGACY_PROXY_BUTTON_ID} { position: absolute !important; left: -9999px !important; top: -9999px !important; width: 1px !important; height: 1px !important; opacity: 0 !important; pointer-events: none !important; } .fmdx-webrtc-mode-select { width: 100%; max-width: 86px; height: 20px; padding: 0 2px; font-size: 10px; line-height: 1; border-radius: 4px; border: 1px solid rgba(255, 255, 255, 0.2); background: rgba(0, 0, 0, 0.45); color: #fff; text-align: center; text-align-last: center; } .fmdx-webrtc-mode-select:hover, .fmdx-webrtc-mode-select:focus, .fmdx-webrtc-mode-select:active { color: #fff !important; background: rgba(0, 0, 0, 0.45) !important; } .fmdx-webrtc-mode-select option { color: #fff; background: #000; } .fmdx-webrtc-mode-select:focus { outline: 1px solid var(--color-main-bright); } .${MANAGED_PLAYBUTTON_CLASS}.rtc-connecting { animation: rtc-breathe 1.8s ease-in-out infinite; transform-origin: center; will-change: transform, box-shadow, background-color, border-color, filter; background-color: rgba(255, 165, 0, 0.14) !important; border-color: rgba(255, 165, 0, 0.45) !important; } .${MANAGED_PLAYBUTTON_CLASS}.rtc-connecting i { color: orange; animation: rtc-pulse 1.1s ease-in-out infinite; transform-origin: center; } @keyframes rtc-breathe { 0%, 100% { transform: scale(1); box-shadow: 0 0 0 rgba(255, 165, 0, 0), 0 0 0 rgba(255, 165, 0, 0); background-color: rgba(255, 165, 0, 0.10); border-color: rgba(255, 165, 0, 0.28); filter: brightness(1); } 50% { transform: scale(0.93); box-shadow: 0 0 18px rgba(255, 165, 0, 0.32), 0 0 30px rgba(255, 165, 0, 0.20); background-color: rgba(255, 165, 0, 0.24); border-color: rgba(255, 165, 0, 0.70); filter: brightness(1.12); } } @keyframes rtc-pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.55; transform: scale(0.82); } }`; let disablews = false; let disableMobileSelect = true; let peerConnection = null; let audioElement = null; let isActive = false; let isConnecting = false; let shouldReconnect = false; let reconnectAttempts = 0; let reconnectTimeout = null; let profiles = []; let currentBitrateIndex = 0; let currentBitrate = null; let whepSessionUrl = null; let onDemandStartup404Attempts = 0; let isPeerConnectionReady = false; let hasAudioPlaybackStarted = false; let audioPlaybackProgressTimeout = null; let currentDebugSessionId = 0; let currentDebugSessionStartedAt = 0; function getAudioElementDebugState(element = audioElement) { if (!element) { return { hasElement: false }; } return { hasElement: true, paused: element.paused, muted: element.muted, volume: element.volume, currentTime: Number(element.currentTime || 0).toFixed(3), readyState: element.readyState, networkState: element.networkState }; } function logWebRTCDebug(message, details = undefined) { if (!DEBUG_WEBRTC_AUDIO) return; const elapsedMs = currentDebugSessionStartedAt ? Math.round(performance.now() - currentDebugSessionStartedAt) : 0; const prefix = `[WHEP][debug s${currentDebugSessionId} +${elapsedMs}ms] ${message}`; if (typeof details === 'undefined') { console.log(prefix); return; } console.log(prefix, details); } function clearAudioPlaybackProgressCheck() { if (audioPlaybackProgressTimeout) { clearTimeout(audioPlaybackProgressTimeout); audioPlaybackProgressTimeout = null; } } function getWhepConfigUrl() { return new URL(WHEP_CONFIG_PATH.replace(/^\//, ''), window.location.origin + '/').toString(); } function getWhepFlagsUrl() { return new URL(WHEP_FLAGS_PATH.replace(/^\//, ''), window.location.origin + '/').toString(); } async function fetchFlags() { try { const response = await fetch(getWhepFlagsUrl(), { method: 'GET', cache: 'no-store' }); if (!response.ok) { return; } const flags = await response.json(); disablews = Boolean(flags.disablews); } catch (error) { console.warn('[WHEP] Failed to fetch flags:', error); } } function getCurrentProfile() { return profiles[currentBitrateIndex] || null; } function getCookieDomainAttribute() { const hostname = String(window.location.hostname || '').toLowerCase(); if (hostname === 'fmtuner.org' || hostname.endsWith('.fmtuner.org')) { return '; domain=.fmtuner.org'; } return ''; } function setPersistentCookie(name, value, days = 365) { const expires = new Date(Date.now() + (days * 24 * 60 * 60 * 1000)).toUTCString(); const secure = window.location.protocol === 'https:' ? '; Secure' : ''; document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/; SameSite=Lax${getCookieDomainAttribute()}${secure}`; } function getCookie(name) { return document.cookie.split('; ').reduce((result, entry) => { const separatorIndex = entry.indexOf('='); if (separatorIndex === -1) { return result; } const cookieName = entry.slice(0, separatorIndex); return cookieName === name ? decodeURIComponent(entry.slice(separatorIndex + 1)) : result; }, ''); } function getStoredBitrate() { const storedBitrate = Number(getCookie(LAST_BITRATE_COOKIE_NAME)); return Number.isFinite(storedBitrate) && storedBitrate > 0 ? storedBitrate : null; } function getStoredSource() { return getCookie(LAST_SOURCE_COOKIE_NAME); } function persistSelectedBitrate() { if (Number.isFinite(currentBitrate) && currentBitrate > 0) { setPersistentCookie(LAST_BITRATE_COOKIE_NAME, currentBitrate); } } function persistSelectedSource(value) { if (value) setPersistentCookie(LAST_SOURCE_COOKIE_NAME, value); } function parseWhepProfiles(rawConfig) { return String(rawConfig || '') .split(';') .map((entry) => entry.trim()) .filter(Boolean) .map((entry) => { const separatorIndex = entry.indexOf(':'); if (separatorIndex <= 0) { return null; } const bitrate = Number(entry.slice(0, separatorIndex).trim()); const url = entry.slice(separatorIndex + 1).trim(); if (!Number.isFinite(bitrate) || bitrate <= 0 || !url) { return null; } try { return { bitrate, url: new URL(url).toString() }; } catch (error) { console.warn('[WHEP] Ignoring invalid URL in config:', entry, error); return null; } }) .filter(Boolean) .sort((a, b) => a.bitrate - b.bitrate); } function formatBitrate(bitrate) { return Math.round(bitrate / 1000) + 'k'; } function formatWebRtcOptionLabel(bitrate) { return `WebRTC ${formatBitrate(bitrate)}`; } function getLegacyOptionLabel() { return 'Websocket'; } function getSelectedBitrateLabel() { return currentBitrate ? formatWebRtcOptionLabel(currentBitrate) : 'WebRTC'; } function parseSelectedBitrate(value) { if (typeof value !== 'string' || !value.startsWith('webrtc:')) return null; const bitrate = Number(value.slice('webrtc:'.length)); return Number.isFinite(bitrate) && bitrate > 0 ? bitrate : null; } function updateVolume() { if (audioElement) { const volume = parseFloat($('#volumeSlider').val()); audioElement.volume = Number.isFinite(volume) ? volume : 1; // Kuba's patches, not techkrzysiek code if(!Number.isFinite(volume)) logWebRTCDebug("Volume is not finite."); } else logWebRTCDebug("audioElement not initialized.") logWebRTCDebug("Volume update."); } function initVolumeControl() { const volumeSlider = document.getElementById('volumeSlider'); if (volumeSlider) { volumeSlider.addEventListener('input', updateVolume); logWebRTCDebug("Should be updating volume."); } else logWebRTCDebug("volumeSlider not found") updateVolume(); } function ensureAudioElement() { if (audioElement) return audioElement; audioElement = new Audio(); audioElement.autoplay = true; audioElement.preload = 'none'; audioElement.style.display = 'none'; audioElement.setAttribute('data-webrtc-audio', 'true'); ['loadstart', 'loadedmetadata', 'loadeddata', 'canplay', 'canplaythrough', 'play', 'playing', 'timeupdate', 'pause', 'waiting', 'stalled', 'suspend', 'ended', 'error'] .forEach((eventName) => { audioElement.addEventListener(eventName, () => { logWebRTCDebug(`audio event: ${eventName}`, getAudioElementDebugState(audioElement)); }); }); audioElement.addEventListener('playing', () => { scheduleAudioPlaybackProgressCheck('playing'); }); audioElement.addEventListener('timeupdate', () => { maybeMarkAudioPlaybackStarted('timeupdate'); }); document.body.appendChild(audioElement); updateVolume(); return audioElement; } function maybeMarkAudioPlaybackStarted(source) { if (!audioElement || hasAudioPlaybackStarted) return; const currentTime = Number(audioElement.currentTime || 0); if (currentTime <= 0.05) { logWebRTCDebug(`audio progress not started yet (${source})`, { currentTime: currentTime.toFixed(3), audio: getAudioElementDebugState() }); return; } logWebRTCDebug(`markAudioPlaybackStarted() from ${source}`, getAudioElementDebugState()); hasAudioPlaybackStarted = true; clearAudioPlaybackProgressCheck(); maybeFinalizeWebRTCConnected(); } function scheduleAudioPlaybackProgressCheck(source, remainingAttempts = 40) { clearAudioPlaybackProgressCheck(); const checkProgress = () => { if (!audioElement || hasAudioPlaybackStarted || !isConnecting) { audioPlaybackProgressTimeout = null; return; } maybeMarkAudioPlaybackStarted(`timer:${source}`); if (hasAudioPlaybackStarted || remainingAttempts <= 0) { audioPlaybackProgressTimeout = null; return; } remainingAttempts--; audioPlaybackProgressTimeout = setTimeout(checkProgress, 100); }; audioPlaybackProgressTimeout = setTimeout(checkProgress, 100); } function maybeFinalizeWebRTCConnected() { logWebRTCDebug('maybeFinalizeWebRTCConnected()', { isPeerConnectionReady, hasAudioPlaybackStarted, isActive, isConnecting, audio: getAudioElementDebugState() }); if (!peerConnection || !isPeerConnectionReady || !hasAudioPlaybackStarted || isActive) return; isConnecting = false; isActive = true; reconnectAttempts = 0; onDemandStartup404Attempts = 0; updateButtonState('connected'); logWebRTCDebug('UI switched to connected', { profile: getSelectedBitrateLabel(), audio: getAudioElementDebugState() }); sendToast('success', 'WebRTC Audio', `Connected (${getSelectedBitrateLabel()})`, false, false); } function getManagedPlayButtons() { return $(`.${MANAGED_PLAYBUTTON_CLASS}`); } function getLegacyProxyButton() { return $(`#${LEGACY_PROXY_BUTTON_ID}`); } function getDesktopPlayButton() { const $managedButton = getManagedPlayButtons() .filter((_, element) => $(element).closest('.hide-phone').length > 0) .first(); if ($managedButton.length > 0) { return $managedButton; } return $('.playbutton') .filter((_, element) => $(element).closest('.hide-phone').length > 0) .first(); } function getMobilePlayButton() { const $managedButton = getManagedPlayButtons() .filter((_, element) => $(element).closest('#mobileTray').length > 0) .first(); if ($managedButton.length > 0) return $managedButton; return $('#mobileTray .playbutton').first(); } function ensureManagedDesktopPlayButton() { let $desktopButton = getDesktopPlayButton(); if ($desktopButton.length === 0) return $desktopButton; if ($desktopButton.hasClass(MANAGED_PLAYBUTTON_CLASS)) return $desktopButton; const $proxyButton = $desktopButton; const $managedButton = $proxyButton.clone(false); const $placeholder = $(''); $managedButton.removeClass('playbutton').addClass(MANAGED_PLAYBUTTON_CLASS); $managedButton.off(); $proxyButton.after($placeholder); $proxyButton .attr('id', LEGACY_PROXY_BUTTON_ID) .attr('aria-hidden', 'true') .attr('tabindex', '-1') .appendTo(document.body); $placeholder.replaceWith($managedButton); return $managedButton; } function ensureManagedMobilePlayButton() { let $mobileButton = getMobilePlayButton(); if ($mobileButton.length === 0) return $mobileButton; if ($mobileButton.hasClass(MANAGED_PLAYBUTTON_CLASS)) return $mobileButton; $mobileButton.removeClass('playbutton').addClass(MANAGED_PLAYBUTTON_CLASS); $mobileButton.off(); return $mobileButton; } function getLegacyProxyState() { const $proxyButton = getLegacyProxyButton(); if ($proxyButton.length === 0) { return isLegacyPlaybackActive() ? 'connected' : 'disconnected'; } const $icon = $proxyButton.find('i'); if ($icon.hasClass('fa-stop')) return 'connected'; return 'disconnected'; } function syncManagedButtonWithLegacyState() { const legacyState = getLegacyProxyState(); const $buttons = getManagedPlayButtons(); $buttons.removeClass('rtc-active rtc-connecting'); if (legacyState === 'connected') $buttons.addClass('rtc-active'); updatePlayButtonIcon(legacyState === 'connected' ? 'connected' : 'disconnected'); updatePlayButtonTitle(legacyState === 'connected' ? 'connected' : 'disconnected'); } function updatePlayButtonIcon(state) { const $icons = getManagedPlayButtons().find('i'); $icons.removeClass('fa-play fa-stop fa-spinner fa-spin'); switch (state) { case 'connecting': $icons.addClass('fa-spinner fa-spin'); break; case 'connected': $icons.addClass('fa-stop'); break; default: $icons.addClass('fa-play'); break; } } function updatePlayButtonTitle(state) { const selectionLabel = getSelectedSourceLabel(); const title = state === 'connected' || state === 'connecting' ? `Stop ${selectionLabel}` : `Play ${selectionLabel}`; getManagedPlayButtons() .attr('title', title) .attr('aria-label', title); } function updateButtonState(state) { const $buttons = getManagedPlayButtons(); $buttons.removeClass('rtc-active rtc-connecting'); switch (state) { case 'connecting': $buttons.addClass('rtc-connecting'); updatePlayButtonIcon('connecting'); break; case 'connected': $buttons.addClass('rtc-active'); updatePlayButtonIcon('connected'); break; default: updatePlayButtonIcon('disconnected'); break; } updatePlayButtonTitle(state); } function normalizeStoredSourceValue(value) { if (value === LEGACY_SOURCE_VALUE) return value; const storedBitrate = parseSelectedBitrate(value); if (storedBitrate) { return `webrtc:${storedBitrate}`; } const legacyBitrate = getStoredBitrate(); return legacyBitrate ? `webrtc:${legacyBitrate}` : ''; } function getDefaultSourceValue() { const storedValue = normalizeStoredSourceValue(getStoredSource()); if (storedValue === LEGACY_SOURCE_VALUE && !disablews) return LEGACY_SOURCE_VALUE; const storedBitrate = parseSelectedBitrate(storedValue); if (profiles.length === 0) return storedValue || SELECT_LOADING_VALUE; const matchingProfile = Number.isFinite(storedBitrate) ? profiles.find((profile) => profile.bitrate === storedBitrate) : null; const fallbackProfile = matchingProfile || profiles[profiles.length - 1]; return `webrtc:${fallbackProfile.bitrate}`; } function applySelectedSourceValue(value, { persist = true, syncSelect = true } = {}) { const $selects = $(`.fmdx-webrtc-mode-select`); const buttonState = getActivePlaybackBackend() ? 'connected' : 'disconnected'; if (value === LEGACY_SOURCE_VALUE && !disablews) { if (syncSelect) $selects.val(LEGACY_SOURCE_VALUE); if (persist) persistSelectedSource(LEGACY_SOURCE_VALUE); updatePlayButtonTitle(buttonState); return LEGACY_SOURCE_VALUE; } if (profiles.length === 0) { if (syncSelect) $selects.val(SELECT_LOADING_VALUE); updatePlayButtonTitle(buttonState); return SELECT_LOADING_VALUE; } const requestedBitrate = parseSelectedBitrate(value); const selectedIndex = Number.isFinite(requestedBitrate) ? profiles.findIndex((profile) => profile.bitrate === requestedBitrate) : -1; currentBitrateIndex = selectedIndex >= 0 ? selectedIndex : profiles.length - 1; currentBitrate = profiles[currentBitrateIndex].bitrate; persistSelectedBitrate(); const appliedValue = `webrtc:${currentBitrate}`; if (syncSelect) $selects.val(appliedValue); if (persist) persistSelectedSource(appliedValue); updatePlayButtonTitle(buttonState); return appliedValue; } function getSelectedSourceValue() { const $selects = $(`.fmdx-webrtc-mode-select`); for (let i = 0; i < $selects.length; i++) { const value = $selects[i].value; if (value && value !== SELECT_LOADING_VALUE) return value; } return getDefaultSourceValue(); } function getSelectedSourceLabel() { const selectedValue = getSelectedSourceValue(); if (selectedValue === LEGACY_SOURCE_VALUE) return getLegacyOptionLabel(); const selectedBitrate = parseSelectedBitrate(selectedValue) ?? currentBitrate; return selectedBitrate ? formatWebRtcOptionLabel(selectedBitrate) : 'WebRTC'; } function isLegacyPlaybackActive() { return typeof Stream !== 'undefined' && Boolean(Stream); } function getActivePlaybackBackend() { if (isConnecting || isActive || shouldReconnect) return 'webrtc'; if (isLegacyPlaybackActive()) return LEGACY_SOURCE_VALUE; return null; } function stopLegacyPlayback() { if (typeof OnPlayButtonClick === 'function' && isLegacyPlaybackActive()) { OnPlayButtonClick(); setTimeout(() => { syncManagedButtonWithLegacyState(); }, 0); } } function startLegacyPlayback() { if (typeof OnPlayButtonClick === 'function' && !isLegacyPlaybackActive()) { OnPlayButtonClick(); setTimeout(() => { syncManagedButtonWithLegacyState(); }, 0); } } function buildOptionsHtml() { const options = profiles .slice() .sort((a, b) => b.bitrate - a.bitrate) .map((profile) => ({ value: `webrtc:${profile.bitrate}`, label: formatWebRtcOptionLabel(profile.bitrate) })); if (options.length === 0) { options.push({ value: SELECT_LOADING_VALUE, label: 'WebRTC...', disabled: true }); } if (!disablews) { options.push({ value: LEGACY_SOURCE_VALUE, label: getLegacyOptionLabel() }); } return options.map((option) => ` `).join(''); } function attachSelectToButton($button, selectId) { if ($button.length === 0) return null; const $host = $button.closest('.panel-10').length > 0 ? $button.closest('.panel-10') : $button.parent(); $host.addClass('fmdx-webrtc-playbutton-host'); if (!$button.parent().hasClass('fmdx-webrtc-button-stack')) { $button.wrap('
'); } const $stack = $button.parent(); let $select = $stack.find(`#${selectId}`); if ($select.length === 0) { $select = $('