mirror of
https://github.com/alexhopeoconnor/bom-local-service.git
synced 2026-10-03 21:21:59 +10:00
Add radar test page with auto-play and connection monitoring
- Add MVC test page at /radar/{suburb}/{state} demonstrating API usage
- Implement auto-play on page load with cookie-based settings
- Add connection monitoring with automatic retry on API downtime
- Show update status, cache state, and next check times
- Fix NextUpdateTime calculation to account for background service intervals
- Add graceful error handling and visual status indicators
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
using BomLocalService.Utilities;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace BomLocalService.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// MVC controller for serving the radar test/demo page.
|
||||
/// This demonstrates best practices for consuming the radar API.
|
||||
/// </summary>
|
||||
[Route("radar")]
|
||||
public class RadarTestController : Controller
|
||||
{
|
||||
/// <summary>
|
||||
/// Serves the test page for viewing radar data for a specific location.
|
||||
/// Route: /radar/{suburb}/{state}
|
||||
/// </summary>
|
||||
[HttpGet("{suburb}/{state}")]
|
||||
public IActionResult Index(string suburb, string state)
|
||||
{
|
||||
var validationError = ValidationHelper.ValidateLocation(suburb, state);
|
||||
if (validationError != null)
|
||||
{
|
||||
return BadRequest(validationError);
|
||||
}
|
||||
|
||||
ViewBag.Suburb = suburb;
|
||||
ViewBag.State = state;
|
||||
ViewBag.ApiBaseUrl = $"/api/radar/{Uri.EscapeDataString(suburb)}/{Uri.EscapeDataString(state)}";
|
||||
|
||||
return View();
|
||||
}
|
||||
}
|
||||
|
||||
+9
-1
@@ -4,7 +4,8 @@ using BomLocalService.Services.Interfaces;
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Add services to the container
|
||||
builder.Services.AddControllers();
|
||||
// AddControllersWithViews includes AddControllers, so we use it for both MVC and API controllers
|
||||
builder.Services.AddControllersWithViews();
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddOpenApi();
|
||||
|
||||
@@ -45,6 +46,13 @@ if (enableHttpsRedirection)
|
||||
}
|
||||
|
||||
// No authorization required - service is designed to run behind a reverse proxy if auth is needed
|
||||
|
||||
// Map MVC routes first (before API routes to avoid conflicts)
|
||||
app.MapControllerRoute(
|
||||
name: "default",
|
||||
pattern: "{controller=RadarTest}/{action=Index}/{suburb?}/{state?}");
|
||||
|
||||
// Map API controllers (with /api prefix)
|
||||
app.MapControllers();
|
||||
|
||||
// Cleanup incomplete cache folders from previous crashes/restarts before starting services
|
||||
|
||||
@@ -14,6 +14,7 @@ public class BomRadarService : IBomRadarService, IDisposable
|
||||
private readonly IScrapingService _scrapingService;
|
||||
private readonly IDebugService _debugService;
|
||||
private readonly double _cacheExpirationMinutes;
|
||||
private readonly int _cacheManagementCheckIntervalMinutes;
|
||||
private readonly ConcurrentDictionary<string, string> _activeCacheFolders = new(); // locationKey -> cacheFolderPath
|
||||
|
||||
public BomRadarService(
|
||||
@@ -30,6 +31,7 @@ public class BomRadarService : IBomRadarService, IDisposable
|
||||
_scrapingService = scrapingService;
|
||||
_debugService = debugService;
|
||||
_cacheExpirationMinutes = configuration.GetValue<double>("CacheExpirationMinutes", 15.5);
|
||||
_cacheManagementCheckIntervalMinutes = configuration.GetValue<int>("CacheManagement:CheckIntervalMinutes", 5);
|
||||
}
|
||||
|
||||
public async Task<RadarResponse?> GetCachedRadarAsync(string suburb, string state, CancellationToken cancellationToken = default)
|
||||
@@ -54,7 +56,7 @@ public class BomRadarService : IBomRadarService, IDisposable
|
||||
var cacheExpiresAt = cachedMetadata != null ? cachedMetadata.ObservationTime.AddMinutes(_cacheExpirationMinutes) : (DateTime?)null;
|
||||
var isUpdating = _activeCacheFolders.ContainsKey(locationKey);
|
||||
|
||||
return ResponseBuilder.CreateRadarResponse(cacheFolderPath, frames, cachedMetadata, suburb, state, isValid, cacheExpiresAt, isUpdating);
|
||||
return ResponseBuilder.CreateRadarResponse(cacheFolderPath, frames, cachedMetadata, suburb, state, isValid, cacheExpiresAt, isUpdating, _cacheManagementCheckIntervalMinutes);
|
||||
}
|
||||
|
||||
public async Task<List<RadarFrame>?> GetCachedFramesAsync(string suburb, string state, CancellationToken cancellationToken = default)
|
||||
@@ -95,6 +97,10 @@ public class BomRadarService : IBomRadarService, IDisposable
|
||||
status.CacheIsValid = _cacheService.IsCacheValid(cachedMetadata);
|
||||
status.CacheExpiresAt = cachedMetadata.ObservationTime.AddMinutes(_cacheExpirationMinutes);
|
||||
}
|
||||
else
|
||||
{
|
||||
status.CacheIsValid = false;
|
||||
}
|
||||
|
||||
status.UpdateTriggered = false;
|
||||
status.Message = "Cache update already in progress";
|
||||
@@ -109,6 +115,11 @@ public class BomRadarService : IBomRadarService, IDisposable
|
||||
status.CacheIsValid = _cacheService.IsCacheValid(cachedMetadata);
|
||||
status.CacheExpiresAt = cachedMetadata.ObservationTime.AddMinutes(_cacheExpirationMinutes);
|
||||
}
|
||||
else
|
||||
{
|
||||
// If no metadata, cache cannot be valid
|
||||
status.CacheIsValid = false;
|
||||
}
|
||||
|
||||
// Check if we need to update
|
||||
bool needsUpdate = !status.CacheExists || !status.CacheIsValid;
|
||||
@@ -172,7 +183,7 @@ public class BomRadarService : IBomRadarService, IDisposable
|
||||
var frames = await _cacheService.GetCachedFramesAsync(suburb, state, cancellationToken);
|
||||
var isValid = cachedMetadata != null && _cacheService.IsCacheValid(cachedMetadata);
|
||||
var cacheExpiresAt = cachedMetadata != null ? cachedMetadata.ObservationTime.AddMinutes(_cacheExpirationMinutes) : (DateTime?)null;
|
||||
return ResponseBuilder.CreateRadarResponse(cacheFolderPath, frames, cachedMetadata, suburb, state, isValid, cacheExpiresAt, isUpdating: true);
|
||||
return ResponseBuilder.CreateRadarResponse(cacheFolderPath, frames, cachedMetadata, suburb, state, isValid, cacheExpiresAt, isUpdating: true, _cacheManagementCheckIntervalMinutes);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -189,7 +200,7 @@ public class BomRadarService : IBomRadarService, IDisposable
|
||||
_logger.LogInformation("Returning valid cached screenshots for {Suburb}, {State} (no semaphore needed)", suburb, state);
|
||||
var frames = await _cacheService.GetCachedFramesAsync(suburb, state, cancellationToken);
|
||||
var cacheExpiresAt = cachedMetadata.ObservationTime.AddMinutes(_cacheExpirationMinutes);
|
||||
return ResponseBuilder.CreateRadarResponse(cacheFolderPath, frames, cachedMetadata, suburb, state, isValid, cacheExpiresAt, isUpdating: false);
|
||||
return ResponseBuilder.CreateRadarResponse(cacheFolderPath, frames, cachedMetadata, suburb, state, isValid, cacheExpiresAt, isUpdating: false, _cacheManagementCheckIntervalMinutes);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -257,7 +268,7 @@ public class BomRadarService : IBomRadarService, IDisposable
|
||||
_logger.LogInformation("Cache became valid while waiting for semaphore, returning cached screenshots");
|
||||
var recheckFrames = await _cacheService.GetCachedFramesAsync(suburb, state, cancellationToken);
|
||||
var recheckCacheExpiresAt = recheckCachedMetadata.ObservationTime.AddMinutes(_cacheExpirationMinutes);
|
||||
return ResponseBuilder.CreateRadarResponse(recheckCacheFolderPath, recheckFrames, recheckCachedMetadata, suburb, state, cacheIsValid: true, recheckCacheExpiresAt, isUpdating: false);
|
||||
return ResponseBuilder.CreateRadarResponse(recheckCacheFolderPath, recheckFrames, recheckCachedMetadata, suburb, state, cacheIsValid: true, recheckCacheExpiresAt, isUpdating: false, _cacheManagementCheckIntervalMinutes);
|
||||
}
|
||||
|
||||
// Create debug folder only now
|
||||
|
||||
@@ -147,12 +147,17 @@ public class CacheManagementService : BackgroundService
|
||||
suburb, state, status.CacheExpiresAt);
|
||||
return (true, false);
|
||||
}
|
||||
else
|
||||
else if (status.CacheIsValid)
|
||||
{
|
||||
_logger.LogInformation("Cache is valid for {Suburb}, {State} (expires at {ExpiresAt})",
|
||||
suburb, state, status.CacheExpiresAt);
|
||||
suburb, state, status.CacheExpiresAt?.ToString("yyyy-MM-dd HH:mm:ss UTC") ?? "unknown");
|
||||
return (false, true);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("Cache exists but is invalid for {Suburb}, {State}", suburb, state);
|
||||
return (false, false);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -541,7 +541,9 @@ public class ScrapingService : IScrapingService
|
||||
}
|
||||
|
||||
// Step 22: Return response with all frames
|
||||
return ResponseBuilder.CreateRadarResponse(cacheFolderPath, frames, lastUpdatedInfo, suburb, state);
|
||||
// Note: ScrapingService doesn't have access to cache management check interval
|
||||
// Default to 5 minutes (standard check interval)
|
||||
return ResponseBuilder.CreateRadarResponse(cacheFolderPath, frames, lastUpdatedInfo, suburb, state, cacheIsValid: true, cacheExpiresAt: null, isUpdating: false, cacheManagementCheckIntervalMinutes: 5);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -7,6 +7,7 @@ public static class ResponseBuilder
|
||||
/// <summary>
|
||||
/// Creates a RadarResponse from a cache folder path, frames, and metadata
|
||||
/// </summary>
|
||||
/// <param name="cacheManagementCheckIntervalMinutes">The interval in minutes that the background cache management service checks for updates. Used to calculate NextUpdateTime when cache is invalid.</param>
|
||||
public static RadarResponse CreateRadarResponse(
|
||||
string cacheFolderPath,
|
||||
List<RadarFrame> frames,
|
||||
@@ -15,7 +16,8 @@ public static class ResponseBuilder
|
||||
string? state = null,
|
||||
bool? cacheIsValid = null,
|
||||
DateTime? cacheExpiresAt = null,
|
||||
bool isUpdating = false)
|
||||
bool isUpdating = false,
|
||||
int cacheManagementCheckIntervalMinutes = 5)
|
||||
{
|
||||
var folderInfo = new DirectoryInfo(cacheFolderPath);
|
||||
var lastWriteTime = folderInfo.Exists
|
||||
@@ -33,6 +35,35 @@ public static class ResponseBuilder
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate NextUpdateTime based on cache status:
|
||||
// - If cache is valid: NextUpdateTime = max(CacheExpiresAt, next background service check)
|
||||
// (Background service checks every N minutes, so update might happen at next check after expiry)
|
||||
// - If cache is invalid and updating: NextUpdateTime = estimated completion time (~2 minutes)
|
||||
// - If cache is invalid and NOT updating: NextUpdateTime = next background service check (based on check interval)
|
||||
DateTime? nextUpdateTime = null;
|
||||
|
||||
// Calculate next background service check time (rounds up to next check interval)
|
||||
var now = DateTime.UtcNow;
|
||||
var minutesUntilNextCheck = cacheManagementCheckIntervalMinutes - (now.Minute % cacheManagementCheckIntervalMinutes);
|
||||
var nextServiceCheck = now.AddMinutes(minutesUntilNextCheck);
|
||||
|
||||
if (isUpdating)
|
||||
{
|
||||
// Update in progress - estimate completion in ~2 minutes
|
||||
nextUpdateTime = now.AddMinutes(2);
|
||||
}
|
||||
else if (cacheIsValid == true && cacheExpiresAt.HasValue)
|
||||
{
|
||||
// Cache is valid - next update will be when cache expires OR next service check, whichever is later
|
||||
// This accounts for the fact that the background service only checks every N minutes
|
||||
nextUpdateTime = cacheExpiresAt.Value > nextServiceCheck ? cacheExpiresAt.Value : nextServiceCheck;
|
||||
}
|
||||
else if (cacheIsValid == false)
|
||||
{
|
||||
// Cache is invalid and not updating - next update will be at next background service check
|
||||
nextUpdateTime = nextServiceCheck;
|
||||
}
|
||||
|
||||
var response = new RadarResponse
|
||||
{
|
||||
Frames = frames,
|
||||
@@ -44,7 +75,7 @@ public static class ResponseBuilder
|
||||
CacheIsValid = cacheIsValid ?? false,
|
||||
CacheExpiresAt = cacheExpiresAt,
|
||||
IsUpdating = isUpdating,
|
||||
NextUpdateTime = cacheExpiresAt ?? (isUpdating ? DateTime.UtcNow.AddMinutes(2) : null) // Estimate 2 min for update if in progress
|
||||
NextUpdateTime = nextUpdateTime
|
||||
};
|
||||
|
||||
return response;
|
||||
|
||||
@@ -0,0 +1,903 @@
|
||||
@{
|
||||
ViewData["Title"] = $"BOM Radar Test - {ViewBag.Suburb}, {ViewBag.State}";
|
||||
var apiBaseUrl = ViewBag.ApiBaseUrl as string ?? "";
|
||||
var suburb = ViewBag.Suburb as string ?? "";
|
||||
var state = ViewBag.State as string ?? "";
|
||||
}
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>@ViewData["Title"]</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 2.5em;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.header .subtitle {
|
||||
font-size: 1.1em;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
gap: 20px;
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
@@media (max-width: 968px) {
|
||||
.content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.radar-section {
|
||||
background: #f8f9fa;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.radar-image-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
background: #000;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 20px;
|
||||
aspect-ratio: 16/9;
|
||||
}
|
||||
|
||||
.radar-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.loading {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: white;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.frame-controls {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.frame-btn {
|
||||
padding: 10px 20px;
|
||||
border: 2px solid #667eea;
|
||||
background: white;
|
||||
color: #667eea;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.frame-btn:hover {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.frame-btn.active {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.frame-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.play-controls {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.play-btn {
|
||||
padding: 12px 30px;
|
||||
border: none;
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
font-size: 1em;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.play-btn:hover {
|
||||
background: #5568d3;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.play-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.info-section {
|
||||
background: #f8f9fa;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.info-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
margin-bottom: 15px;
|
||||
border-left: 4px solid #667eea;
|
||||
}
|
||||
|
||||
.info-card h3 {
|
||||
font-size: 0.9em;
|
||||
text-transform: uppercase;
|
||||
color: #666;
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.info-card .value {
|
||||
font-size: 1.2em;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.85em;
|
||||
font-weight: 600;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.status-valid {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.status-invalid {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.status-updating {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
margin: 20px;
|
||||
border-left: 4px solid #dc3545;
|
||||
animation: fadeIn 0.3s;
|
||||
}
|
||||
|
||||
.error.retrying {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
border-left-color: #ffc107;
|
||||
}
|
||||
|
||||
@@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
font-size: 0.85em;
|
||||
color: #666;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.refresh-indicator {
|
||||
display: inline-block;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: #28a745;
|
||||
margin-right: 8px;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.frame-info {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
margin-top: 10px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🌧️ BOM Radar Test</h1>
|
||||
<div class="subtitle">@suburb, @state</div>
|
||||
</div>
|
||||
|
||||
<div id="error-container"></div>
|
||||
|
||||
<div class="content">
|
||||
<div class="radar-section">
|
||||
<div class="radar-image-container">
|
||||
<div class="loading" id="loading">Loading radar data...</div>
|
||||
<img id="radar-image" class="radar-image" style="display: none;" alt="Radar image">
|
||||
</div>
|
||||
|
||||
<div class="frame-controls" id="frame-controls"></div>
|
||||
|
||||
<div class="play-controls">
|
||||
<button class="play-btn" id="play-btn">▶ Play</button>
|
||||
<button class="play-btn" id="prev-btn">◀ Previous</button>
|
||||
<button class="play-btn" id="next-btn">Next ▶</button>
|
||||
</div>
|
||||
|
||||
<div class="frame-info" id="frame-info"></div>
|
||||
</div>
|
||||
|
||||
<div class="info-section">
|
||||
<div class="info-card">
|
||||
<h3>Cache Status</h3>
|
||||
<div class="value" id="cache-status">Checking...</div>
|
||||
</div>
|
||||
|
||||
<div class="info-card">
|
||||
<h3>Observation Time</h3>
|
||||
<div class="value" id="observation-time">-</div>
|
||||
</div>
|
||||
|
||||
<div class="info-card">
|
||||
<h3>Cache Expires</h3>
|
||||
<div class="value" id="cache-expires">-</div>
|
||||
<div class="timestamp" id="cache-expires-relative"></div>
|
||||
</div>
|
||||
|
||||
<div class="info-card">
|
||||
<h3>Next Update</h3>
|
||||
<div class="value" id="next-update">-</div>
|
||||
<div class="timestamp" id="next-update-relative"></div>
|
||||
</div>
|
||||
|
||||
<div class="info-card">
|
||||
<h3>Weather Station</h3>
|
||||
<div class="value" id="weather-station">-</div>
|
||||
</div>
|
||||
|
||||
<div class="info-card">
|
||||
<h3>Distance</h3>
|
||||
<div class="value" id="distance">-</div>
|
||||
</div>
|
||||
|
||||
<div class="info-card">
|
||||
<h3>Update Status</h3>
|
||||
<div class="value" id="update-status">-</div>
|
||||
<div class="timestamp" id="update-status-detail"></div>
|
||||
</div>
|
||||
|
||||
<div class="info-card">
|
||||
<h3>Auto Refresh</h3>
|
||||
<div class="value">
|
||||
<span class="refresh-indicator" id="refresh-indicator"></span>
|
||||
<span id="refresh-status">Active</span>
|
||||
</div>
|
||||
<div class="timestamp" id="last-refresh"></div>
|
||||
<div class="timestamp" id="next-client-check"></div>
|
||||
</div>
|
||||
|
||||
<div class="info-card">
|
||||
<h3>Settings</h3>
|
||||
<button class="play-btn" id="settings-btn" style="width: 100%; margin-top: 10px;">⚙️ Settings</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Modal -->
|
||||
<div id="settings-modal" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 1000; align-items: center; justify-content: center;">
|
||||
<div style="background: white; border-radius: 12px; padding: 30px; max-width: 500px; width: 90%; max-height: 90vh; overflow-y: auto;">
|
||||
<h2 style="margin-bottom: 20px; color: #667eea;">Settings</h2>
|
||||
|
||||
<div style="margin-bottom: 20px;">
|
||||
<label style="display: block; margin-bottom: 8px; font-weight: 600; color: #333;">Frame Interval (seconds)</label>
|
||||
<input type="number" id="frame-interval-input" min="0.5" max="10" step="0.5" style="width: 100%; padding: 10px; border: 2px solid #667eea; border-radius: 8px; font-size: 1em;">
|
||||
<div class="timestamp" style="margin-top: 5px;">Time between frames in slideshow</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 20px;">
|
||||
<label style="display: block; margin-bottom: 8px; font-weight: 600; color: #333;">Auto Refresh Interval (seconds)</label>
|
||||
<input type="number" id="refresh-interval-input" min="10" max="300" step="10" style="width: 100%; padding: 10px; border: 2px solid #667eea; border-radius: 8px; font-size: 1em;">
|
||||
<div class="timestamp" style="margin-top: 5px;">How often to check for new radar data</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 20px;">
|
||||
<label style="display: flex; align-items: center; cursor: pointer;">
|
||||
<input type="checkbox" id="auto-play-input" style="width: 20px; height: 20px; margin-right: 10px; cursor: pointer;">
|
||||
<span style="font-weight: 600; color: #333;">Auto-play on page load</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 10px; margin-top: 30px;">
|
||||
<button class="play-btn" id="save-settings-btn" style="flex: 1;">Save</button>
|
||||
<button class="play-btn" id="cancel-settings-btn" style="flex: 1; background: #6c757d;">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const API_BASE = '@apiBaseUrl';
|
||||
let currentFrameIndex = 0;
|
||||
let frames = [];
|
||||
let radarData = null;
|
||||
let playInterval = null;
|
||||
let isPlaying = false;
|
||||
let lastRefreshTime = null;
|
||||
let refreshInterval = null;
|
||||
let nextClientCheckTime = null;
|
||||
let retryInterval = null;
|
||||
let isApiDown = false;
|
||||
let retryAttempts = 0;
|
||||
const MAX_RETRY_ATTEMPTS = 10;
|
||||
const RETRY_DELAY_MS = 5000; // 5 seconds between retries
|
||||
|
||||
// Settings with defaults
|
||||
let settings = {
|
||||
frameInterval: 2.0, // seconds between frames
|
||||
refreshInterval: 30, // seconds between API refreshes
|
||||
autoPlay: true // auto-play on load
|
||||
};
|
||||
|
||||
// Cookie management
|
||||
function setCookie(name, value, days = 365) {
|
||||
const expires = new Date();
|
||||
expires.setTime(expires.getTime() + (days * 24 * 60 * 60 * 1000));
|
||||
document.cookie = `${name}=${encodeURIComponent(JSON.stringify(value))};expires=${expires.toUTCString()};path=/`;
|
||||
}
|
||||
|
||||
function getCookie(name) {
|
||||
const nameEQ = name + "=";
|
||||
const ca = document.cookie.split(';');
|
||||
for (let i = 0; i < ca.length; i++) {
|
||||
let c = ca[i];
|
||||
while (c.charAt(0) === ' ') c = c.substring(1, c.length);
|
||||
if (c.indexOf(nameEQ) === 0) {
|
||||
try {
|
||||
return JSON.parse(decodeURIComponent(c.substring(nameEQ.length, c.length)));
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function loadSettings() {
|
||||
const saved = getCookie('radarTestSettings');
|
||||
if (saved) {
|
||||
settings = { ...settings, ...saved };
|
||||
}
|
||||
updateSettingsUI();
|
||||
}
|
||||
|
||||
function saveSettings() {
|
||||
setCookie('radarTestSettings', settings);
|
||||
updateSettingsUI();
|
||||
applySettings();
|
||||
}
|
||||
|
||||
function updateSettingsUI() {
|
||||
document.getElementById('frame-interval-input').value = settings.frameInterval;
|
||||
document.getElementById('refresh-interval-input').value = settings.refreshInterval;
|
||||
document.getElementById('auto-play-input').checked = settings.autoPlay;
|
||||
}
|
||||
|
||||
function applySettings() {
|
||||
// Restart play interval if playing
|
||||
if (isPlaying) {
|
||||
pause();
|
||||
play();
|
||||
}
|
||||
|
||||
// Restart refresh interval
|
||||
if (refreshInterval) {
|
||||
clearInterval(refreshInterval);
|
||||
}
|
||||
refreshInterval = setInterval(refreshData, settings.refreshInterval * 1000);
|
||||
}
|
||||
|
||||
// Format date for display
|
||||
function formatDate(dateString) {
|
||||
if (!dateString) return '-';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString('en-AU', {
|
||||
timeZone: 'Australia/Brisbane',
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate relative time
|
||||
function getRelativeTime(dateString) {
|
||||
if (!dateString) return '';
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = date - now;
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffSecs = Math.floor((diffMs % 60000) / 1000);
|
||||
|
||||
if (diffMs < 0) {
|
||||
return Math.abs(diffMins) + ' minute(s) ago';
|
||||
} else if (diffMins > 0) {
|
||||
return 'in ' + diffMins + ' minute(s)';
|
||||
} else {
|
||||
return 'in ' + diffSecs + ' second(s)';
|
||||
}
|
||||
}
|
||||
|
||||
// Update relative times periodically
|
||||
function updateRelativeTimes() {
|
||||
if (radarData) {
|
||||
const expiresEl = document.getElementById('cache-expires-relative');
|
||||
const nextUpdateEl = document.getElementById('next-update-relative');
|
||||
const updateStatusDetailEl = document.getElementById('update-status-detail');
|
||||
const nextCheckEl = document.getElementById('next-client-check');
|
||||
|
||||
if (radarData.cacheExpiresAt && expiresEl) {
|
||||
expiresEl.textContent = getRelativeTime(radarData.cacheExpiresAt);
|
||||
}
|
||||
|
||||
if (radarData.nextUpdateTime && nextUpdateEl) {
|
||||
nextUpdateEl.textContent = getRelativeTime(radarData.nextUpdateTime);
|
||||
}
|
||||
|
||||
// Update update status detail with relative time
|
||||
if (radarData.isUpdating && radarData.nextUpdateTime && updateStatusDetailEl) {
|
||||
updateStatusDetailEl.textContent = 'Estimated completion: ' + formatDate(radarData.nextUpdateTime) + ' (' + getRelativeTime(radarData.nextUpdateTime) + ')';
|
||||
} else if (!radarData.cacheIsValid && !radarData.isUpdating && radarData.nextUpdateTime && updateStatusDetailEl) {
|
||||
updateStatusDetailEl.textContent = 'Update scheduled: ' + formatDate(radarData.nextUpdateTime) + ' (' + getRelativeTime(radarData.nextUpdateTime) + ')';
|
||||
}
|
||||
|
||||
// Update next client check time
|
||||
if (nextClientCheckTime && nextCheckEl) {
|
||||
nextCheckEl.textContent = 'Next check: ' + formatDate(nextClientCheckTime.toISOString()) + ' (' + getRelativeTime(nextClientCheckTime.toISOString()) + ')';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch radar data
|
||||
async function fetchRadarData() {
|
||||
try {
|
||||
const response = await fetch(API_BASE, {
|
||||
signal: AbortSignal.timeout(10000) // 10 second timeout
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: `HTTP ${response.status}: ${response.statusText}` }));
|
||||
throw new Error(error.error || 'Failed to fetch radar data');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
lastRefreshTime = new Date();
|
||||
|
||||
// API is working - reset retry state
|
||||
if (isApiDown) {
|
||||
isApiDown = false;
|
||||
retryAttempts = 0;
|
||||
clearError();
|
||||
if (retryInterval) {
|
||||
clearInterval(retryInterval);
|
||||
retryInterval = null;
|
||||
}
|
||||
updateApiStatus('connected');
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching radar data:', error);
|
||||
|
||||
// Check if it's a network error (API down)
|
||||
const isNetworkError = error.name === 'TypeError' ||
|
||||
error.name === 'AbortError' ||
|
||||
error.message.includes('fetch') ||
|
||||
error.message.includes('network') ||
|
||||
error.message.includes('Failed to fetch');
|
||||
|
||||
if (isNetworkError) {
|
||||
handleApiDown(error);
|
||||
} else {
|
||||
showError(error.message);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle API being down
|
||||
function handleApiDown(error) {
|
||||
if (!isApiDown) {
|
||||
isApiDown = true;
|
||||
retryAttempts = 0;
|
||||
updateApiStatus('disconnected');
|
||||
}
|
||||
|
||||
retryAttempts++;
|
||||
showError(`API connection lost. Retrying... (Attempt ${retryAttempts}/${MAX_RETRY_ATTEMPTS})`);
|
||||
|
||||
// Stop auto-refresh while retrying
|
||||
if (refreshInterval) {
|
||||
clearInterval(refreshInterval);
|
||||
refreshInterval = null;
|
||||
}
|
||||
|
||||
// Start retry mechanism
|
||||
if (!retryInterval && retryAttempts < MAX_RETRY_ATTEMPTS) {
|
||||
retryInterval = setInterval(async () => {
|
||||
if (retryAttempts >= MAX_RETRY_ATTEMPTS) {
|
||||
clearInterval(retryInterval);
|
||||
retryInterval = null;
|
||||
showError('API connection failed after multiple attempts. Please check if the service is running.');
|
||||
updateApiStatus('failed');
|
||||
return;
|
||||
}
|
||||
|
||||
retryAttempts++;
|
||||
showError(`API connection lost. Retrying... (Attempt ${retryAttempts}/${MAX_RETRY_ATTEMPTS})`);
|
||||
|
||||
// Try to fetch data
|
||||
const data = await fetchRadarData();
|
||||
if (data) {
|
||||
// Success - updateUI will be called
|
||||
updateUI(data);
|
||||
}
|
||||
}, RETRY_DELAY_MS);
|
||||
} else if (retryAttempts >= MAX_RETRY_ATTEMPTS) {
|
||||
showError('API connection failed after multiple attempts. Please check if the service is running.');
|
||||
updateApiStatus('failed');
|
||||
}
|
||||
}
|
||||
|
||||
// Update API connection status indicator
|
||||
function updateApiStatus(status) {
|
||||
const refreshStatusEl = document.getElementById('refresh-status');
|
||||
const refreshIndicatorEl = document.getElementById('refresh-indicator');
|
||||
|
||||
if (status === 'connected') {
|
||||
refreshStatusEl.textContent = 'Active';
|
||||
refreshIndicatorEl.style.background = '#28a745';
|
||||
refreshIndicatorEl.style.animation = 'pulse 2s infinite';
|
||||
} else if (status === 'disconnected') {
|
||||
refreshStatusEl.textContent = 'Reconnecting...';
|
||||
refreshIndicatorEl.style.background = '#ffc107';
|
||||
refreshIndicatorEl.style.animation = 'pulse 1s infinite';
|
||||
} else if (status === 'failed') {
|
||||
refreshStatusEl.textContent = 'Connection Failed';
|
||||
refreshIndicatorEl.style.background = '#dc3545';
|
||||
refreshIndicatorEl.style.animation = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Show error message
|
||||
function showError(message) {
|
||||
const container = document.getElementById('error-container');
|
||||
const isRetrying = message.includes('Retrying');
|
||||
const errorClass = isRetrying ? 'error retrying' : 'error';
|
||||
container.innerHTML = `<div class="${errorClass}">${isRetrying ? '⚠️ ' : '❌ Error: '}${message}</div>`;
|
||||
}
|
||||
|
||||
// Clear error message
|
||||
function clearError() {
|
||||
document.getElementById('error-container').innerHTML = '';
|
||||
}
|
||||
|
||||
// Update UI with radar data
|
||||
function updateUI(data) {
|
||||
if (!data || !data.frames || data.frames.length === 0) {
|
||||
showError('No radar frames available');
|
||||
return;
|
||||
}
|
||||
|
||||
radarData = data;
|
||||
frames = data.frames.sort((a, b) => a.frameIndex - b.frameIndex);
|
||||
|
||||
// Update cache status
|
||||
const statusEl = document.getElementById('cache-status');
|
||||
let statusHtml = '';
|
||||
if (data.isUpdating) {
|
||||
statusHtml = 'Updating <span class="status-badge status-updating">IN PROGRESS</span>';
|
||||
} else if (data.cacheIsValid) {
|
||||
statusHtml = 'Valid <span class="status-badge status-valid">ACTIVE</span>';
|
||||
} else {
|
||||
statusHtml = 'Invalid <span class="status-badge status-invalid">EXPIRED</span>';
|
||||
}
|
||||
statusEl.innerHTML = statusHtml;
|
||||
|
||||
// Update update status card
|
||||
const updateStatusEl = document.getElementById('update-status');
|
||||
const updateStatusDetailEl = document.getElementById('update-status-detail');
|
||||
if (data.isUpdating) {
|
||||
updateStatusEl.innerHTML = 'Cache Update <span class="status-badge status-updating">IN PROGRESS</span>';
|
||||
if (data.nextUpdateTime) {
|
||||
updateStatusDetailEl.textContent = 'Estimated completion: ' + formatDate(data.nextUpdateTime) + ' (' + getRelativeTime(data.nextUpdateTime) + ')';
|
||||
} else {
|
||||
updateStatusDetailEl.textContent = 'Update in progress, please wait...';
|
||||
}
|
||||
} else if (data.cacheIsValid) {
|
||||
updateStatusEl.innerHTML = 'Cache <span class="status-badge status-valid">UP TO DATE</span>';
|
||||
updateStatusDetailEl.textContent = 'No update needed';
|
||||
} else {
|
||||
updateStatusEl.innerHTML = 'Cache <span class="status-badge status-invalid">STALE</span>';
|
||||
if (data.nextUpdateTime) {
|
||||
updateStatusDetailEl.textContent = 'Update scheduled: ' + formatDate(data.nextUpdateTime) + ' (' + getRelativeTime(data.nextUpdateTime) + ')';
|
||||
} else {
|
||||
updateStatusDetailEl.textContent = 'Update will be triggered by background service';
|
||||
}
|
||||
}
|
||||
|
||||
// Update observation time
|
||||
document.getElementById('observation-time').textContent = formatDate(data.observationTime);
|
||||
|
||||
// Update cache expires
|
||||
document.getElementById('cache-expires').textContent = formatDate(data.cacheExpiresAt);
|
||||
|
||||
// Update next update
|
||||
document.getElementById('next-update').textContent = formatDate(data.nextUpdateTime);
|
||||
|
||||
// Update weather station
|
||||
document.getElementById('weather-station').textContent = data.weatherStation || '-';
|
||||
|
||||
// Update distance
|
||||
document.getElementById('distance').textContent = data.distance || '-';
|
||||
|
||||
// Update last refresh
|
||||
if (lastRefreshTime) {
|
||||
document.getElementById('last-refresh').textContent =
|
||||
'Last checked: ' + formatDate(lastRefreshTime.toISOString());
|
||||
|
||||
// Calculate and display next client check time
|
||||
nextClientCheckTime = new Date(lastRefreshTime.getTime() + (settings.refreshInterval * 1000));
|
||||
const nextCheckEl = document.getElementById('next-client-check');
|
||||
if (nextCheckEl) {
|
||||
nextCheckEl.textContent = 'Next check: ' + formatDate(nextClientCheckTime.toISOString()) + ' (' + getRelativeTime(nextClientCheckTime.toISOString()) + ')';
|
||||
}
|
||||
}
|
||||
|
||||
// Build frame controls
|
||||
const controlsEl = document.getElementById('frame-controls');
|
||||
controlsEl.innerHTML = frames.map((frame, idx) => `
|
||||
<button class="frame-btn" data-frame="${idx}" onclick="showFrame(${idx})">
|
||||
Frame ${frame.frameIndex} (${frame.minutesAgo}m ago)
|
||||
</button>
|
||||
`).join('');
|
||||
|
||||
// Show first frame
|
||||
if (frames.length > 0) {
|
||||
showFrame(0);
|
||||
|
||||
// Auto-play if enabled and not already playing
|
||||
if (settings.autoPlay && !isPlaying) {
|
||||
setTimeout(() => play(), 500);
|
||||
}
|
||||
}
|
||||
|
||||
updateRelativeTimes();
|
||||
}
|
||||
|
||||
// Show specific frame
|
||||
function showFrame(index) {
|
||||
if (index < 0 || index >= frames.length) return;
|
||||
|
||||
currentFrameIndex = index;
|
||||
const frame = frames[index];
|
||||
|
||||
// Update image
|
||||
const imgEl = document.getElementById('radar-image');
|
||||
const loadingEl = document.getElementById('loading');
|
||||
|
||||
imgEl.onload = () => {
|
||||
loadingEl.style.display = 'none';
|
||||
imgEl.style.display = 'block';
|
||||
};
|
||||
|
||||
imgEl.src = frame.imageUrl;
|
||||
imgEl.alt = `Radar frame ${frame.frameIndex} (${frame.minutesAgo} minutes ago)`;
|
||||
|
||||
// Update frame info
|
||||
document.getElementById('frame-info').textContent =
|
||||
`Frame ${frame.frameIndex} of ${frames.length - 1} • ${frame.minutesAgo} minutes ago`;
|
||||
|
||||
// Update active button
|
||||
document.querySelectorAll('.frame-btn').forEach((btn, idx) => {
|
||||
btn.classList.toggle('active', idx === index);
|
||||
});
|
||||
}
|
||||
|
||||
// Play/pause slideshow
|
||||
function togglePlay() {
|
||||
if (isPlaying) {
|
||||
pause();
|
||||
} else {
|
||||
play();
|
||||
}
|
||||
}
|
||||
|
||||
function play() {
|
||||
if (frames.length === 0) return;
|
||||
|
||||
isPlaying = true;
|
||||
document.getElementById('play-btn').textContent = '⏸ Pause';
|
||||
|
||||
if (playInterval) {
|
||||
clearInterval(playInterval);
|
||||
}
|
||||
|
||||
playInterval = setInterval(() => {
|
||||
currentFrameIndex = (currentFrameIndex + 1) % frames.length;
|
||||
showFrame(currentFrameIndex);
|
||||
}, settings.frameInterval * 1000);
|
||||
}
|
||||
|
||||
function pause() {
|
||||
isPlaying = false;
|
||||
document.getElementById('play-btn').textContent = '▶ Play';
|
||||
|
||||
if (playInterval) {
|
||||
clearInterval(playInterval);
|
||||
playInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Previous/Next frame
|
||||
function previousFrame() {
|
||||
pause();
|
||||
const newIndex = currentFrameIndex > 0 ? currentFrameIndex - 1 : frames.length - 1;
|
||||
showFrame(newIndex);
|
||||
}
|
||||
|
||||
function nextFrame() {
|
||||
pause();
|
||||
const newIndex = (currentFrameIndex + 1) % frames.length;
|
||||
showFrame(newIndex);
|
||||
}
|
||||
|
||||
// Auto-refresh data
|
||||
async function refreshData() {
|
||||
// Don't clear error if API is down (keep retry message visible)
|
||||
if (!isApiDown) {
|
||||
clearError();
|
||||
}
|
||||
|
||||
const data = await fetchRadarData();
|
||||
if (data) {
|
||||
updateUI(data);
|
||||
|
||||
// Restart auto-refresh if it was stopped
|
||||
if (!refreshInterval && !isApiDown) {
|
||||
refreshInterval = setInterval(refreshData, settings.refreshInterval * 1000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Settings modal management
|
||||
function showSettings() {
|
||||
document.getElementById('settings-modal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function hideSettings() {
|
||||
document.getElementById('settings-modal').style.display = 'none';
|
||||
}
|
||||
|
||||
// Initialize
|
||||
async function init() {
|
||||
// Load settings from cookies
|
||||
loadSettings();
|
||||
|
||||
// Set up event listeners
|
||||
document.getElementById('play-btn').addEventListener('click', togglePlay);
|
||||
document.getElementById('prev-btn').addEventListener('click', previousFrame);
|
||||
document.getElementById('next-btn').addEventListener('click', nextFrame);
|
||||
document.getElementById('settings-btn').addEventListener('click', showSettings);
|
||||
document.getElementById('save-settings-btn').addEventListener('click', () => {
|
||||
settings.frameInterval = parseFloat(document.getElementById('frame-interval-input').value) || 2.0;
|
||||
settings.refreshInterval = parseInt(document.getElementById('refresh-interval-input').value) || 30;
|
||||
settings.autoPlay = document.getElementById('auto-play-input').checked;
|
||||
saveSettings();
|
||||
hideSettings();
|
||||
});
|
||||
document.getElementById('cancel-settings-btn').addEventListener('click', () => {
|
||||
updateSettingsUI(); // Reset to current settings
|
||||
hideSettings();
|
||||
});
|
||||
|
||||
// Close modal on background click
|
||||
document.getElementById('settings-modal').addEventListener('click', (e) => {
|
||||
if (e.target.id === 'settings-modal') {
|
||||
hideSettings();
|
||||
}
|
||||
});
|
||||
|
||||
// Initial load
|
||||
await refreshData();
|
||||
|
||||
// Auto-refresh based on settings (only if API is up)
|
||||
if (!isApiDown) {
|
||||
refreshInterval = setInterval(refreshData, settings.refreshInterval * 1000);
|
||||
}
|
||||
|
||||
// Update relative times every second
|
||||
setInterval(updateRelativeTimes, 1000);
|
||||
|
||||
// Auto-play if enabled
|
||||
if (settings.autoPlay && frames.length > 0) {
|
||||
setTimeout(() => play(), 1000); // Small delay to ensure frames are loaded
|
||||
}
|
||||
}
|
||||
|
||||
// Start when page loads
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user