1(function () { 2 'use strict'; 3 4 const config = window.JSFMGrowth || {}; 5 const audio = document.querySelector('[data-jsfm-audio]'); 6 const playButtons = document.querySelectorAll('[data-jsfm-play]'); 7 const player = document.querySelector('[data-jsfm-player]'); 8 let listenedSeconds = Number(sessionStorage.getItem('jsfmListenedSeconds') || 0); 9 let listenTimer = null; 10 let activeAudio = null; 11 12 function identifier(storage, key) { 13 let value = storage.getItem(key); 14 if (!value) { 15 value = window.crypto?.randomUUID?.() || (Date.now().toString(16) + Math.random().toString(16).slice(2)); 16 storage.setItem(key, value); 17 } 18 return value; 19 } 20 21 const sessionId = identifier(sessionStorage, 'jsfmSessionId'); 22 const listenerId = identifier(localStorage, 'jsfmListenerId'); 23 24 function attribution() { 25 const params = new URLSearchParams(window.location.search); 26 const incoming = { 27 campaign: params.get('utm_campaign') || '', 28 source: params.get('utm_source') || '', 29 medium: params.get('utm_medium') || '' 30 }; 31 if (incoming.campaign || incoming.source || incoming.medium) { 32 localStorage.setItem('jsfmAttribution', JSON.stringify(incoming)); 33 return incoming; 34 } 35 try { 36 return JSON.parse(localStorage.getItem('jsfmAttribution') || '{}'); 37 } catch (error) { 38 return {}; 39 } 40 } 41 42 const conversion = document.querySelector('.jsfm-conversion'); 43 if (conversion && conversion !== document.body.firstElementChild) { 44 document.body.insertBefore(conversion, document.body.firstChild); 45 } 46 47 function campaign() { 48 const params = new URLSearchParams(window.location.search); 49 return params.get('utm_campaign') || document.querySelector('[data-campaign]')?.dataset.campaign || attribution().campaign || ''; 50 } 51 52 function market() { 53 const params = new URLSearchParams(window.location.search); 54 return params.get('utm_content') || document.documentElement.lang || 'en-US'; 55 } 56 57 function track(event, details) { 58 const source = attribution(); 59 const payload = Object.assign({ 60 event, 61 session_id: sessionId, 62 listener_id: listenerId, 63 campaign: campaign(), 64 source: source.source || '', 65 medium: source.medium || '', 66 market: market(), 67 path: window.location.pathname, 68 platform: /Android|iPhone|iPad|Mobile/i.test(navigator.userAgent) ? 'mobile-web' : 'desktop-web' 69 }, details || {}); 70 window.dataLayer = window.dataLayer || []; 71 window.dataLayer.push(payload); 72 window.dispatchEvent(new CustomEvent('jetsetfm:' + event, { detail: payload })); 73 if (config.restUrl) { 74 navigator.sendBeacon?.(config.restUrl + 'analytics', new Blob([JSON.stringify(payload)], { type: 'application/json' })); 75 } 76 } 77 78 function setPlaying(playing) { 79 playButtons.forEach((button) => { 80 button.textContent = playing ? 'Pause' : 'Listen Live'; 81 button.setAttribute('aria-label', playing ? 'Pause JetSetFM' : 'Play JetSetFM'); 82 }); 83 player?.classList.toggle('is-playing', playing); 84 } 85 86 function beginListening(media) { 87 activeAudio = media; 88 setPlaying(true); 89 if (!sessionStorage.getItem('jsfmListenStarted')) { 90 track('listen_start'); 91 sessionStorage.setItem('jsfmListenStarted', '1'); 92 const prior = Number(localStorage.getItem('jsfmLastListen') || 0); 93 if (prior && Date.now() - prior <= 7 * 24 * 60 * 60 * 1000) track('listen_return_7d'); 94 localStorage.setItem('jsfmLastListen', String(Date.now())); 95 } 96 clearInterval(listenTimer); 97 listenTimer = window.setInterval(() => { 98 if (!activeAudio || activeAudio.paused || activeAudio.ended) return; 99 listenedSeconds += 1; 100 sessionStorage.setItem('jsfmListenedSeconds', String(listenedSeconds)); 101 if (listenedSeconds >= 1800 && !sessionStorage.getItem('jsfmListen30Reported')) { 102 track('listen_30_minutes'); 103 sessionStorage.setItem('jsfmListen30Reported', '1'); 104 } 105 }, 1000); 106 } 107 108 function stopListening(media) { 109 if (activeAudio !== media) return; 110 clearInterval(listenTimer); 111 setPlaying(false); 112 } 113 114 function observeAudio(media) { 115 if (media.dataset.jsfmObserved) return; 116 media.dataset.jsfmObserved = '1'; 117 media.addEventListener('playing', () => beginListening(media)); 118 media.addEventListener('pause', () => stopListening(media)); 119 media.addEventListener('ended', () => stopListening(media)); 120 } 121 122 function isGenericAlt(alt) { 123 return ['', 'image', 'cover', 'chart track'].includes(String(alt || '').trim().toLowerCase()); 124 } 125 126 function altFromSrc(src, currentAlt) {
127 const known = [ 128 ['1000x1000bb-1', "Album artwork for I Ain't No Joke by Eric B. & Rakim"], 129 ['1000x1000bb-2', 'Album artwork for Christmas In Hollis by Run-DMC'], 130 ['1000x1000bb-3', 'Album artwork for Needle To the Groove by Mantronix'], 131 ['jetsetfm-hip-hop', 'JetSetFM hip-hop radio culture feature artwork'], 132 ['jetsetfm-disco', 'JetSetFM disco music history feature artwork'], 133 ['jetsetfm-radio-music', 'JetSetFM internet radio music feature artwork'], 134 ['pexels-photo-3642350', 'Vintage cassette tapes representing classic hip-hop tracks'], 135 ['jet600x600', 'JetSetFM station artwork'] 136 ]; 137 const lowerSrc = String(src || '').toLowerCase(); 138 const match = known.find(([needle]) => lowerSrc.includes(needle)); 139 if (match) return match[1]; 140 const normalized = String(currentAlt || '').trim().toLowerCase(); 141 if (normalized === 'chart track') return 'Chart track artwork'; 142 if (normalized === 'cover') return 'Album cover artwork'; 143 return 'JetSetFM image'; 144 } 145 146 function repairImageAlt(image) { 147 if (!(image instanceof HTMLImageElement) || !isGenericAlt(image.getAttribute('alt'))) return; 148 image.setAttribute('alt', altFromSrc(image.currentSrc || image.src, image.getAttribute('alt'))); 149 } 150 151 document.querySelectorAll('audio').forEach(observeAudio); 152 document.querySelectorAll('img').forEach(repairImageAlt); 153 new MutationObserver((records) => records.forEach((record) => record.addedNodes.forEach((node) => { 154 if (!(node instanceof Element)) return; 155 if (node.matches('audio')) observeAudio(node); 156 node.querySelectorAll?.('audio').forEach(observeAudio); 157 if (node.matches('img')) repairImageAlt(node); 158 node.querySelectorAll?.('img').forEach(repairImageAlt); 159 }))).observe(document.documentElement, { childList: true, subtree: true }); 160 161 playButtons.forEach((button) => button.addEventListener('click', async () => { 162 if (!audio) return; 163 if (audio.paused) { 164 try { 165 await audio.play(); 166 } catch (error) { 167 setPlaying(false); 168 } 169 } else { 170 audio.pause(); 171 } 172 })); 173 174 document.querySelectorAll('[data-jsfm-event]').forEach((element) => { 175 element.addEventListener('click', () => track(element.dataset.jsfmEvent, { store: element.dataset.store || '' })); 176 }); 177 178 function clearCurrentMetadata(message) { 179 document.querySelectorAll('[data-jsfm-track-title]').forEach((el) => { el.textContent = message; }); 180 document.querySelectorAll('[data-jsfm-track-artist]').forEach((el) => { el.textContent = ''; }); 181 document.querySelectorAll('[data-jsfm-now-playing]').forEach((el) => { el.textContent = message; }); 182 } 183 184 let metadataLoading = false; 185 async function loadMetadata() { 186 if (!config.restUrl) return; 187 if (metadataLoading) return; 188 metadataLoading = true; 189 const controller = new AbortController(); 190 const timeout = window.setTimeout(() => controller.abort(), 8000); 191 try { 192 const response = await fetch(config.restUrl + 'now-playing', { cache: 'no-store', signal: controller.signal }); 193 if (!response.ok) throw new Error('Metadata unavailable'); 194 const data = await response.json(); 195 const checkedAt = Date.parse(data?.checked_at || ''); 196 const fresh = Number.isFinite(checkedAt) && Date.now() - checkedAt <= 120000 && checkedAt - Date.now() <= 60000; 197 if (!fresh) { 198 clearCurrentMetadata('Track information temporarily unavailable'); 199 } else if (data.status === 'break') { 200 clearCurrentMetadata('Station break'); 201 } else if (data.status === 'playing' && typeof data.current?.title === 'string' && data.current.title.trim()) { 202 document.querySelectorAll('[data-jsfm-track-title]').forEach((el) => { el.textContent = data.current.title; }); 203 document.querySelectorAll('[data-jsfm-track-artist]').forEach((el) => { el.textContent = data.current.artist; }); 204 document.querySelectorAll('[data-jsfm-now-playing]').forEach((el) => { el.textContent = 'Now playing: ' + data.current.artist + ' - ' + data.current.title; }); 205 } else { 206 clearCurrentMetadata('Track information temporarily unavailable'); 207 } 208 if (Array.isArray(data.recent)) { 209 document.querySelectorAll('[data-jsfm-recent-list]').forEach((list) => { 210 list.replaceChildren(...data.recent.slice(0, 10).map((track) => { 211 const item = document.createElement('li'); 212 item.textContent = track.artist + ' - ' + track.title; 213 return item; 214 })); 215 }); 216 } 217 } catch (error) { 218 // The live stream remains available when metadata is temporarily unavailable. 219 clearCurrentMetadata('Track information temporarily unavailable'); 220 } finally { 221 window.clearTimeout(timeout); 222 metadataLoading = false;
223 } 224 } 225 loadMetadata(); 226 window.setInterval(loadMetadata, 30000); 227 228 document.querySelectorAll('[data-jsfm-reminder]').forEach((button) => button.addEventListener('click', () => { 229 const chicagoParts = Object.fromEntries(new Intl.DateTimeFormat('en-US', { 230 timeZone: 'America/Chicago', year: 'numeric', month: '2-digit', day: '2-digit', weekday: 'short' 231 }).formatToParts(new Date()).filter((part) => part.type !== 'literal').map((part) => [part.type, part.value])); 232 const weekdays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; 233 const base = new Date(Date.UTC(Number(chicagoParts.year), Number(chicagoParts.month) - 1, Number(chicagoParts.day))); 234 const daysUntilFriday = (5 - weekdays.indexOf(chicagoParts.weekday) + 7) % 7 || 7; 235 base.setUTCDate(base.getUTCDate() + daysUntilFriday); 236 const localDate = base.toISOString().slice(0, 10).replace(/-/g, ''); 237 const stamp = (date) => date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, ''); 238 const ics = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'X-WR-TIMEZONE:America/Chicago', 'BEGIN:VEVENT', 'UID:[email protected]', 'DTSTAMP:' + stamp(new Date()), 'DTSTART;TZID=America/Chicago:' + localDate + 'T190000', 'DTEND;TZID=America/Chicago:' + localDate + 'T210000', 'RRULE:FREQ=WEEKLY;BYDAY=FR', 'SUMMARY:' + (config.showName || 'The First Class Friday Flight'), 'URL:https://jetsetfm.com/', 'END:VEVENT', 'END:VCALENDAR'].join('\r\n'); 239 const link = document.createElement('a'); 240 link.href = URL.createObjectURL(new Blob([ics], { type: 'text/calendar' })); 241 link.download = 'jetsetfm-friday-flight.ics'; 242 link.click(); 243 URL.revokeObjectURL(link.href); 244 track('show_reminder'); 245 })); 246 247 async function submitForm(form, endpoint, event) { 248 const status = form.querySelector('[data-jsfm-status]'); 249 const payload = Object.fromEntries(new FormData(form).entries()); 250 status.textContent = 'Sending...'; 251 try { 252 const response = await fetch(config.restUrl + endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); 253 const data = await response.json(); 254 status.textContent = data.message || (response.ok ? 'Done.' : 'Unable to send.'); 255 if (response.ok) { 256 form.reset(); 257 track(event); 258 } 259 } catch (error) { 260 status.textContent = 'Unable to send right now. Please try again.'; 261 } 262 } 263 264 document.querySelectorAll('[data-jsfm-email-form]').forEach((form) => form.addEventListener('submit', (event) => { 265 event.preventDefault(); submitForm(form, 'email-signup', 'email_signup'); 266 })); 267 document.querySelectorAll('[data-jsfm-request-form]').forEach((form) => form.addEventListener('submit', (event) => { 268 event.preventDefault(); submitForm(form, 'track-request', 'track_request'); 269 })); 270 271 document.querySelectorAll('a[href*="support-jetsetfm"], a[href*="buymeacoffee.com/jetsetfm"]').forEach((link) => link.addEventListener('click', () => track('support_conversion'))); 272}());
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.