Compare commits
7 Commits
61d3e490da
...
a11f64b636
| Author | SHA1 | Date |
|---|---|---|
|
|
a11f64b636 | |
|
|
75b27c5424 | |
|
|
009e812f8c | |
|
|
b29e504bb3 | |
|
|
2cd128260c | |
|
|
8b0f7e7705 | |
|
|
edb17a71c4 |
|
|
@ -1281,6 +1281,18 @@
|
||||||
("total_minutes" . ,(third row))))
|
("total_minutes" . ,(third row))))
|
||||||
stats))))))
|
stats))))))
|
||||||
|
|
||||||
|
(define-api asteroid/stats/geo/cities (country &optional (days "7")) ()
|
||||||
|
"Get city breakdown for a specific country (admin only)"
|
||||||
|
(require-role :admin)
|
||||||
|
(let ((stats (get-geo-stats-by-city country (parse-integer days :junk-allowed t))))
|
||||||
|
(api-output `(("status" . "success")
|
||||||
|
("country" . ,country)
|
||||||
|
("cities" . ,(mapcar (lambda (row)
|
||||||
|
`(("city" . ,(or (first row) "Unknown"))
|
||||||
|
("listeners" . ,(second row))
|
||||||
|
("minutes" . ,(third row))))
|
||||||
|
stats))))))
|
||||||
|
|
||||||
;; RADIANCE server management functions
|
;; RADIANCE server management functions
|
||||||
|
|
||||||
(defun start-server (&key (port *server-port*))
|
(defun start-server (&key (port *server-port*))
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
<header-timeout>15</header-timeout>
|
<header-timeout>15</header-timeout>
|
||||||
<source-timeout>10</source-timeout>
|
<source-timeout>10</source-timeout>
|
||||||
<burst-on-connect>1</burst-on-connect>
|
<burst-on-connect>1</burst-on-connect>
|
||||||
<burst-size>65535</burst-size>
|
<burst-size>500000</burst-size>
|
||||||
</limits>
|
</limits>
|
||||||
|
|
||||||
<authentication>
|
<authentication>
|
||||||
|
|
|
||||||
|
|
@ -265,18 +265,19 @@
|
||||||
(error (e)
|
(error (e)
|
||||||
(log:error "Session cleanup failed: ~a" e))))
|
(log:error "Session cleanup failed: ~a" e))))
|
||||||
|
|
||||||
(defun update-geo-stats (country-code listener-count)
|
(defun update-geo-stats (country-code listener-count &optional city)
|
||||||
"Update geo stats for today"
|
"Update geo stats for today, optionally including city"
|
||||||
(when country-code
|
(when country-code
|
||||||
(handler-case
|
(handler-case
|
||||||
(with-db
|
(with-db
|
||||||
(postmodern:execute
|
(let ((city-sql (if city (format nil "'~a'" city) "NULL")))
|
||||||
(format nil "INSERT INTO listener_geo_stats (date, country_code, listener_count, listen_minutes)
|
(postmodern:execute
|
||||||
VALUES (CURRENT_DATE, '~a', ~a, 1)
|
(format nil "INSERT INTO listener_geo_stats (date, country_code, city, listener_count, listen_minutes)
|
||||||
ON CONFLICT (date, country_code)
|
VALUES (CURRENT_DATE, '~a', ~a, ~a, 1)
|
||||||
|
ON CONFLICT (date, country_code, city)
|
||||||
DO UPDATE SET listener_count = listener_geo_stats.listener_count + ~a,
|
DO UPDATE SET listener_count = listener_geo_stats.listener_count + ~a,
|
||||||
listen_minutes = listener_geo_stats.listen_minutes + 1"
|
listen_minutes = listener_geo_stats.listen_minutes + 1"
|
||||||
country-code listener-count listener-count)))
|
country-code city-sql listener-count listener-count))))
|
||||||
(error (e)
|
(error (e)
|
||||||
(log:error "Failed to update geo stats: ~a" e)))))
|
(log:error "Failed to update geo stats: ~a" e)))))
|
||||||
|
|
||||||
|
|
@ -381,6 +382,22 @@
|
||||||
(log:error "Failed to get geo stats: ~a" e)
|
(log:error "Failed to get geo stats: ~a" e)
|
||||||
nil)))
|
nil)))
|
||||||
|
|
||||||
|
(defun get-geo-stats-by-city (country-code &optional (days 7))
|
||||||
|
"Get city breakdown for a specific country for the last N days"
|
||||||
|
(handler-case
|
||||||
|
(with-db
|
||||||
|
(postmodern:query
|
||||||
|
(format nil "SELECT city, SUM(listener_count) as total_listeners, SUM(listen_minutes) as total_minutes
|
||||||
|
FROM listener_geo_stats
|
||||||
|
WHERE date > NOW() - INTERVAL '~a days'
|
||||||
|
AND country_code = '~a'
|
||||||
|
GROUP BY city
|
||||||
|
ORDER BY total_listeners DESC
|
||||||
|
LIMIT 10" days country-code)))
|
||||||
|
(error (e)
|
||||||
|
(log:error "Failed to get city stats for ~a: ~a" country-code e)
|
||||||
|
nil)))
|
||||||
|
|
||||||
(defun get-user-listening-stats (user-id)
|
(defun get-user-listening-stats (user-id)
|
||||||
"Get listening statistics for a specific user"
|
"Get listening statistics for a specific user"
|
||||||
(handler-case
|
(handler-case
|
||||||
|
|
@ -402,52 +419,55 @@
|
||||||
;;; Polling Service
|
;;; Polling Service
|
||||||
|
|
||||||
(defun get-cached-geo (ip)
|
(defun get-cached-geo (ip)
|
||||||
"Get cached geo data for IP, or lookup and cache"
|
"Get cached geo data for IP, or lookup and cache. Returns (country . city) or nil."
|
||||||
(let* ((ip-hash (hash-ip-address ip))
|
(let* ((ip-hash (hash-ip-address ip))
|
||||||
(cached (gethash ip-hash *geo-cache*)))
|
(cached (gethash ip-hash *geo-cache*)))
|
||||||
(if (and cached (< (- (get-universal-time) (getf cached :time)) *geo-cache-ttl*))
|
(if (and cached (< (- (get-universal-time) (getf cached :time)) *geo-cache-ttl*))
|
||||||
(getf cached :country)
|
(cons (getf cached :country) (getf cached :city))
|
||||||
;; Lookup and cache
|
;; Lookup and cache
|
||||||
(let ((geo (lookup-geoip ip)))
|
(let ((geo (lookup-geoip ip)))
|
||||||
(when geo
|
(when geo
|
||||||
(let ((country (getf geo :country-code)))
|
(let ((country (getf geo :country-code))
|
||||||
|
(city (getf geo :city)))
|
||||||
(setf (gethash ip-hash *geo-cache*)
|
(setf (gethash ip-hash *geo-cache*)
|
||||||
(list :country country :time (get-universal-time)))
|
(list :country country :city city :time (get-universal-time)))
|
||||||
country))))))
|
(cons country city)))))))
|
||||||
|
|
||||||
(defun collect-geo-stats-for-mount (mount)
|
(defun collect-geo-stats-for-mount (mount)
|
||||||
"Collect geo stats for all listeners on a mount (from Icecast - may show proxy IPs)"
|
"Collect geo stats for all listeners on a mount (from Icecast - may show proxy IPs)"
|
||||||
(let ((listclients-xml (fetch-icecast-listclients mount)))
|
(let ((listclients-xml (fetch-icecast-listclients mount)))
|
||||||
(when listclients-xml
|
(when listclients-xml
|
||||||
(let ((ips (extract-listener-ips listclients-xml))
|
(let ((ips (extract-listener-ips listclients-xml))
|
||||||
(country-counts (make-hash-table :test 'equal)))
|
(location-counts (make-hash-table :test 'equal)))
|
||||||
;; Group by country
|
;; Group by country+city
|
||||||
(dolist (ip ips)
|
(dolist (ip ips)
|
||||||
(let ((country (get-cached-geo ip)))
|
(let ((geo (get-cached-geo ip))) ; Returns (country . city) or nil
|
||||||
(when country
|
(when geo
|
||||||
(incf (gethash country country-counts 0)))))
|
(incf (gethash geo location-counts 0)))))
|
||||||
;; Store each country's count
|
;; Store each country+city count
|
||||||
(maphash (lambda (country count)
|
(maphash (lambda (key count)
|
||||||
(update-geo-stats country count))
|
(update-geo-stats (car key) count (cdr key)))
|
||||||
country-counts)))))
|
location-counts)))))
|
||||||
|
|
||||||
(defun collect-geo-stats-from-web-listeners ()
|
(defun collect-geo-stats-from-web-listeners ()
|
||||||
"Collect geo stats from web listeners (uses real IPs from X-Forwarded-For)"
|
"Collect geo stats from web listeners (uses real IPs from X-Forwarded-For)"
|
||||||
(cleanup-stale-web-listeners)
|
(cleanup-stale-web-listeners)
|
||||||
(let ((country-counts (make-hash-table :test 'equal)))
|
(let ((location-counts (make-hash-table :test 'equal)))
|
||||||
;; Count listeners by country from cached geo data
|
;; Count listeners by country+city from cached geo data
|
||||||
(maphash (lambda (session-id data)
|
(maphash (lambda (session-id data)
|
||||||
(declare (ignore session-id))
|
(declare (ignore session-id))
|
||||||
(let* ((ip-hash (getf data :ip-hash))
|
(let* ((ip-hash (getf data :ip-hash))
|
||||||
(cached-geo (gethash ip-hash *geo-cache*))
|
(cached-geo (gethash ip-hash *geo-cache*))
|
||||||
(country (when cached-geo (getf cached-geo :country))))
|
(country (when cached-geo (getf cached-geo :country)))
|
||||||
(when country
|
(city (when cached-geo (getf cached-geo :city)))
|
||||||
(incf (gethash country country-counts 0)))))
|
(key (when country (cons country city))))
|
||||||
|
(when key
|
||||||
|
(incf (gethash key location-counts 0)))))
|
||||||
*web-listeners*)
|
*web-listeners*)
|
||||||
;; Store each country's count
|
;; Store each country+city count
|
||||||
(maphash (lambda (country count)
|
(maphash (lambda (key count)
|
||||||
(update-geo-stats country count))
|
(update-geo-stats (car key) count (cdr key)))
|
||||||
country-counts)))
|
location-counts)))
|
||||||
|
|
||||||
(defun poll-and-store-stats ()
|
(defun poll-and-store-stats ()
|
||||||
"Single poll iteration: fetch stats and store"
|
"Single poll iteration: fetch stats and store"
|
||||||
|
|
|
||||||
|
|
@ -311,38 +311,61 @@
|
||||||
(defun attach-audio-event-listeners (audio-element)
|
(defun attach-audio-event-listeners (audio-element)
|
||||||
"Attach all necessary event listeners to an audio element"
|
"Attach all necessary event listeners to an audio element"
|
||||||
|
|
||||||
;; Error handler
|
;; Error handler - retry indefinitely with exponential backoff
|
||||||
(ps:chain audio-element
|
(ps:chain audio-element
|
||||||
(add-event-listener "error"
|
(add-event-listener "error"
|
||||||
(lambda (err)
|
(lambda (err)
|
||||||
(incf *stream-error-count*)
|
|
||||||
(ps:chain console (log "Stream error:" err))
|
(ps:chain console (log "Stream error:" err))
|
||||||
|
(when *is-reconnecting*
|
||||||
(if (< *stream-error-count* 3)
|
(return))
|
||||||
;; Auto-retry for first few errors
|
(incf *stream-error-count*)
|
||||||
(progn
|
;; Calculate delay with exponential backoff (3s, 6s, 12s, max 30s)
|
||||||
(show-stream-status (+ "⚠️ Stream error. Reconnecting... (attempt " *stream-error-count* ")") "warning")
|
(let ((delay (ps:chain |Math| (min (* 3000 (ps:chain |Math| (pow 2 (- *stream-error-count* 1)))) 30000))))
|
||||||
(setf *reconnect-timeout*
|
(show-stream-status (+ "⚠️ Stream error. Reconnecting in " (/ delay 1000) "s... (attempt " *stream-error-count* ")") "warning")
|
||||||
(set-timeout reconnect-stream 3000)))
|
(setf *is-reconnecting* t)
|
||||||
;; Too many errors, show manual reconnect
|
(setf *reconnect-timeout*
|
||||||
(progn
|
(set-timeout reconnect-stream delay))))))
|
||||||
(show-stream-status "❌ Stream connection lost. Click Reconnect to try again." "error")
|
|
||||||
(show-reconnect-button))))))
|
|
||||||
|
|
||||||
;; Stalled handler
|
;; Stalled handler
|
||||||
(ps:chain audio-element
|
(ps:chain audio-element
|
||||||
(add-event-listener "stalled"
|
(add-event-listener "stalled"
|
||||||
(lambda ()
|
(lambda ()
|
||||||
(ps:chain console (log "Stream stalled"))
|
(when *is-reconnecting*
|
||||||
(show-stream-status "⚠️ Stream stalled. Attempting to recover..." "warning")
|
(return))
|
||||||
|
(ps:chain console (log "Stream stalled, will auto-reconnect in 5 seconds..."))
|
||||||
|
(show-stream-status "⚠️ Stream stalled - reconnecting..." "warning")
|
||||||
|
(setf *is-reconnecting* t)
|
||||||
(setf *reconnect-timeout*
|
(setf *reconnect-timeout*
|
||||||
(set-timeout
|
(set-timeout
|
||||||
(lambda ()
|
(lambda ()
|
||||||
;; Only reconnect if still stalled
|
;; Only reconnect if still stalled
|
||||||
(when (ps:@ audio-element paused)
|
(if (< (ps:@ audio-element ready-state) 3)
|
||||||
(reconnect-stream)))
|
(reconnect-stream)
|
||||||
|
(setf *is-reconnecting* false)))
|
||||||
5000)))))
|
5000)))))
|
||||||
|
|
||||||
|
;; Ended handler - stream shouldn't end, so reconnect
|
||||||
|
(ps:chain audio-element
|
||||||
|
(add-event-listener "ended"
|
||||||
|
(lambda ()
|
||||||
|
(when *is-reconnecting*
|
||||||
|
(return))
|
||||||
|
(ps:chain console (log "Stream ended unexpectedly, reconnecting..."))
|
||||||
|
(show-stream-status "⚠️ Stream ended - reconnecting..." "warning")
|
||||||
|
(setf *is-reconnecting* t)
|
||||||
|
(set-timeout reconnect-stream 2000))))
|
||||||
|
|
||||||
|
;; Pause handler - detect browser throttling muted streams
|
||||||
|
(ps:chain audio-element
|
||||||
|
(add-event-listener "pause"
|
||||||
|
(lambda ()
|
||||||
|
(when (and (ps:@ audio-element muted)
|
||||||
|
(not *is-reconnecting*))
|
||||||
|
(ps:chain console (log "Stream paused while muted (possible browser throttling), reconnecting..."))
|
||||||
|
(show-stream-status "⚠️ Stream paused - reconnecting..." "warning")
|
||||||
|
(setf *is-reconnecting* t)
|
||||||
|
(set-timeout reconnect-stream 3000)))))
|
||||||
|
|
||||||
;; Waiting handler (buffering)
|
;; Waiting handler (buffering)
|
||||||
(ps:chain audio-element
|
(ps:chain audio-element
|
||||||
(add-event-listener "waiting"
|
(add-event-listener "waiting"
|
||||||
|
|
@ -355,6 +378,7 @@
|
||||||
(add-event-listener "playing"
|
(add-event-listener "playing"
|
||||||
(lambda ()
|
(lambda ()
|
||||||
(setf *stream-error-count* 0)
|
(setf *stream-error-count* 0)
|
||||||
|
(setf *is-reconnecting* false)
|
||||||
(hide-stream-status)
|
(hide-stream-status)
|
||||||
(hide-reconnect-button)
|
(hide-reconnect-button)
|
||||||
(when *reconnect-timeout*
|
(when *reconnect-timeout*
|
||||||
|
|
|
||||||
|
|
@ -336,6 +336,9 @@
|
||||||
return String.fromCodePoint(...codePoints);
|
return String.fromCodePoint(...codePoints);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Track expanded countries
|
||||||
|
const expandedCountries = new Set();
|
||||||
|
|
||||||
// Fetch and display geo stats
|
// Fetch and display geo stats
|
||||||
function refreshGeoStats() {
|
function refreshGeoStats() {
|
||||||
fetch('/api/asteroid/stats/geo?days=7')
|
fetch('/api/asteroid/stats/geo?days=7')
|
||||||
|
|
@ -349,12 +352,19 @@
|
||||||
const country = item.country_code || item[0];
|
const country = item.country_code || item[0];
|
||||||
const listeners = item.total_listeners || item[1] || 0;
|
const listeners = item.total_listeners || item[1] || 0;
|
||||||
const minutes = item.total_minutes || item[2] || 0;
|
const minutes = item.total_minutes || item[2] || 0;
|
||||||
return `<tr>
|
const isExpanded = expandedCountries.has(country);
|
||||||
<td>${countryToFlag(country)} ${country}</td>
|
const arrow = isExpanded ? '▼' : '▶';
|
||||||
|
return `<tr class="country-row" data-country="${country}" style="cursor: pointer;" onclick="toggleCountryCities('${country}')">
|
||||||
|
<td><span class="expand-arrow">${arrow}</span> ${countryToFlag(country)} ${country}</td>
|
||||||
<td>${listeners}</td>
|
<td>${listeners}</td>
|
||||||
<td>${minutes}</td>
|
<td>${minutes}</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="city-rows" id="cities-${country}" style="display: ${isExpanded ? 'table-row' : 'none'};">
|
||||||
|
<td colspan="3" style="padding: 0;"><div class="city-container" id="city-container-${country}"></div></td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
// Re-fetch cities for expanded countries
|
||||||
|
expandedCountries.forEach(country => fetchCities(country));
|
||||||
} else {
|
} else {
|
||||||
tbody.innerHTML = '<tr><td colspan="3" style="color: #888;">No geo data yet</td></tr>';
|
tbody.innerHTML = '<tr><td colspan="3" style="color: #888;">No geo data yet</td></tr>';
|
||||||
}
|
}
|
||||||
|
|
@ -366,6 +376,52 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Toggle city display for a country
|
||||||
|
function toggleCountryCities(country) {
|
||||||
|
const cityRow = document.getElementById(`cities-${country}`);
|
||||||
|
const countryRow = document.querySelector(`tr[data-country="${country}"]`);
|
||||||
|
const arrow = countryRow.querySelector('.expand-arrow');
|
||||||
|
|
||||||
|
if (expandedCountries.has(country)) {
|
||||||
|
expandedCountries.delete(country);
|
||||||
|
cityRow.style.display = 'none';
|
||||||
|
arrow.textContent = '▶';
|
||||||
|
} else {
|
||||||
|
expandedCountries.add(country);
|
||||||
|
cityRow.style.display = 'table-row';
|
||||||
|
arrow.textContent = '▼';
|
||||||
|
fetchCities(country);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch cities for a country
|
||||||
|
function fetchCities(country) {
|
||||||
|
const container = document.getElementById(`city-container-${country}`);
|
||||||
|
container.innerHTML = '<div style="padding: 5px 20px; color: #888;">Loading cities...</div>';
|
||||||
|
|
||||||
|
fetch(`/api/asteroid/stats/geo/cities?country=${country}&days=7`)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(result => {
|
||||||
|
const data = result.data || result;
|
||||||
|
if (data.status === 'success' && data.cities && data.cities.length > 0) {
|
||||||
|
container.innerHTML = '<table style="width: 100%; margin-left: 20px;">' +
|
||||||
|
data.cities.map(city => `
|
||||||
|
<tr style="background: rgba(0,255,0,0.05);">
|
||||||
|
<td style="padding: 3px 10px;">└ ${city.city}</td>
|
||||||
|
<td style="padding: 3px 10px;">${city.listeners}</td>
|
||||||
|
<td style="padding: 3px 10px;">${city.minutes}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('') + '</table>';
|
||||||
|
} else {
|
||||||
|
container.innerHTML = '<div style="padding: 5px 20px; color: #888;">No city data</div>';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error fetching cities:', error);
|
||||||
|
container.innerHTML = '<div style="padding: 5px 20px; color: #ff6666;">Error loading cities</div>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-refresh stats every 30 seconds
|
// Auto-refresh stats every 30 seconds
|
||||||
setInterval(refreshListenerStats, 30000);
|
setInterval(refreshListenerStats, 30000);
|
||||||
setInterval(refreshGeoStats, 60000);
|
setInterval(refreshGeoStats, 60000);
|
||||||
|
|
|
||||||
|
|
@ -54,18 +54,8 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add event listeners for debugging
|
// Note: Main event listeners are attached via attachAudioListeners()
|
||||||
audioElement.addEventListener('waiting', function() {
|
// which is called at the bottom of this script
|
||||||
console.log('Audio buffering...');
|
|
||||||
});
|
|
||||||
|
|
||||||
audioElement.addEventListener('playing', function() {
|
|
||||||
console.log('Audio playing');
|
|
||||||
});
|
|
||||||
|
|
||||||
audioElement.addEventListener('error', function(e) {
|
|
||||||
console.error('Audio error:', e);
|
|
||||||
});
|
|
||||||
|
|
||||||
const selector = document.getElementById('stream-quality');
|
const selector = document.getElementById('stream-quality');
|
||||||
const streamQuality = localStorage.getItem('stream-quality') || 'aac';
|
const streamQuality = localStorage.getItem('stream-quality') || 'aac';
|
||||||
|
|
@ -251,6 +241,11 @@
|
||||||
}, 300);
|
}, 300);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Error retry counter and reconnect state
|
||||||
|
var streamErrorCount = 0;
|
||||||
|
var reconnectTimeout = null;
|
||||||
|
var isReconnecting = false;
|
||||||
|
|
||||||
// Attach event listeners to audio element
|
// Attach event listeners to audio element
|
||||||
function attachAudioListeners(audioElement) {
|
function attachAudioListeners(audioElement) {
|
||||||
audioElement.addEventListener('waiting', function() {
|
audioElement.addEventListener('waiting', function() {
|
||||||
|
|
@ -260,16 +255,63 @@
|
||||||
audioElement.addEventListener('playing', function() {
|
audioElement.addEventListener('playing', function() {
|
||||||
console.log('Audio playing');
|
console.log('Audio playing');
|
||||||
hideStatus();
|
hideStatus();
|
||||||
|
streamErrorCount = 0; // Reset error count on successful play
|
||||||
|
isReconnecting = false; // Reset reconnecting flag
|
||||||
|
if (reconnectTimeout) {
|
||||||
|
clearTimeout(reconnectTimeout);
|
||||||
|
reconnectTimeout = null;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
audioElement.addEventListener('error', function(e) {
|
audioElement.addEventListener('error', function(e) {
|
||||||
console.error('Audio error:', e);
|
console.error('Audio error:', e);
|
||||||
showStatus('⚠️ Stream error - click 🔄 to reconnect', true);
|
if (isReconnecting) return; // Already reconnecting, skip
|
||||||
|
streamErrorCount++;
|
||||||
|
// Calculate delay with exponential backoff (3s, 6s, 12s, max 30s)
|
||||||
|
var delay = Math.min(3000 * Math.pow(2, streamErrorCount - 1), 30000);
|
||||||
|
showStatus('⚠️ Stream error. Reconnecting in ' + (delay/1000) + 's... (attempt ' + streamErrorCount + ')', true);
|
||||||
|
isReconnecting = true;
|
||||||
|
reconnectTimeout = setTimeout(function() {
|
||||||
|
reconnectStream();
|
||||||
|
}, delay);
|
||||||
});
|
});
|
||||||
|
|
||||||
audioElement.addEventListener('stalled', function() {
|
audioElement.addEventListener('stalled', function() {
|
||||||
console.log('Audio stalled');
|
if (isReconnecting) return; // Already reconnecting, skip
|
||||||
showStatus('⚠️ Stream stalled - click 🔄 if no audio', true);
|
console.log('Audio stalled, will auto-reconnect in 5 seconds...');
|
||||||
|
showStatus('⚠️ Stream stalled - reconnecting...', true);
|
||||||
|
isReconnecting = true;
|
||||||
|
setTimeout(function() {
|
||||||
|
if (audioElement.readyState < 3) {
|
||||||
|
reconnectStream();
|
||||||
|
} else {
|
||||||
|
isReconnecting = false;
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle ended event - stream shouldn't end, so reconnect
|
||||||
|
audioElement.addEventListener('ended', function() {
|
||||||
|
if (isReconnecting) return; // Already reconnecting, skip
|
||||||
|
console.log('Stream ended unexpectedly, reconnecting...');
|
||||||
|
showStatus('⚠️ Stream ended - reconnecting...', true);
|
||||||
|
isReconnecting = true;
|
||||||
|
setTimeout(function() {
|
||||||
|
reconnectStream();
|
||||||
|
}, 2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle pause event - detect browser throttling muted streams
|
||||||
|
audioElement.addEventListener('pause', function() {
|
||||||
|
// If paused while muted and we didn't initiate it, browser may have throttled
|
||||||
|
if (audioElement.muted && !isReconnecting) {
|
||||||
|
console.log('Stream paused while muted (possible browser throttling), will reconnect in 3 seconds...');
|
||||||
|
showStatus('⚠️ Stream paused - reconnecting...', true);
|
||||||
|
isReconnecting = true;
|
||||||
|
setTimeout(function() {
|
||||||
|
reconnectStream();
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -127,18 +127,67 @@
|
||||||
|
|
||||||
// Auto-reconnect on stream errors
|
// Auto-reconnect on stream errors
|
||||||
const audioElement = document.getElementById('live-audio');
|
const audioElement = document.getElementById('live-audio');
|
||||||
|
var streamErrorCount = 0;
|
||||||
|
var reconnectTimeout = null;
|
||||||
|
var isReconnecting = false;
|
||||||
|
|
||||||
|
audioElement.addEventListener('playing', function() {
|
||||||
|
console.log('Audio playing');
|
||||||
|
streamErrorCount = 0;
|
||||||
|
isReconnecting = false;
|
||||||
|
if (reconnectTimeout) {
|
||||||
|
clearTimeout(reconnectTimeout);
|
||||||
|
reconnectTimeout = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
audioElement.addEventListener('error', function(e) {
|
audioElement.addEventListener('error', function(e) {
|
||||||
console.log('Stream error, attempting reconnect in 3 seconds...');
|
console.error('Audio error:', e);
|
||||||
setTimeout(function() {
|
if (isReconnecting) return;
|
||||||
|
streamErrorCount++;
|
||||||
|
var delay = Math.min(3000 * Math.pow(2, streamErrorCount - 1), 30000);
|
||||||
|
console.log('Stream error. Reconnecting in ' + (delay/1000) + 's... (attempt ' + streamErrorCount + ')');
|
||||||
|
isReconnecting = true;
|
||||||
|
reconnectTimeout = setTimeout(function() {
|
||||||
audioElement.load();
|
audioElement.load();
|
||||||
audioElement.play().catch(err => console.log('Reconnect failed:', err));
|
audioElement.play().catch(err => console.log('Reconnect failed:', err));
|
||||||
}, 3000);
|
}, delay);
|
||||||
});
|
});
|
||||||
|
|
||||||
audioElement.addEventListener('stalled', function() {
|
audioElement.addEventListener('stalled', function() {
|
||||||
console.log('Stream stalled, reloading...');
|
if (isReconnecting) return;
|
||||||
audioElement.load();
|
console.log('Stream stalled, will auto-reconnect in 5 seconds...');
|
||||||
audioElement.play().catch(err => console.log('Reload failed:', err));
|
isReconnecting = true;
|
||||||
|
setTimeout(function() {
|
||||||
|
if (audioElement.readyState < 3) {
|
||||||
|
audioElement.load();
|
||||||
|
audioElement.play().catch(err => console.log('Reload failed:', err));
|
||||||
|
} else {
|
||||||
|
isReconnecting = false;
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
audioElement.addEventListener('ended', function() {
|
||||||
|
if (isReconnecting) return;
|
||||||
|
console.log('Stream ended unexpectedly, reconnecting...');
|
||||||
|
isReconnecting = true;
|
||||||
|
setTimeout(function() {
|
||||||
|
audioElement.load();
|
||||||
|
audioElement.play().catch(err => console.log('Reconnect failed:', err));
|
||||||
|
}, 2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle pause event - detect browser throttling muted streams
|
||||||
|
audioElement.addEventListener('pause', function() {
|
||||||
|
if (audioElement.muted && !isReconnecting) {
|
||||||
|
console.log('Stream paused while muted (possible browser throttling), reconnecting...');
|
||||||
|
isReconnecting = true;
|
||||||
|
setTimeout(function() {
|
||||||
|
audioElement.load();
|
||||||
|
audioElement.play().catch(err => console.log('Reconnect failed:', err));
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Notify parent window that popout is open
|
// Notify parent window that popout is open
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue