diff --git a/src/AI.slnx b/src/AIRadioShowcase.slnx
similarity index 100%
rename from src/AI.slnx
rename to src/AIRadioShowcase.slnx
diff --git a/src/AIRadioShowcase/AIRadioShowcase.csproj b/src/AIRadioShowcase/AIRadioShowcase.csproj
index b51965c..81bfd10 100644
--- a/src/AIRadioShowcase/AIRadioShowcase.csproj
+++ b/src/AIRadioShowcase/AIRadioShowcase.csproj
@@ -12,4 +12,73 @@
+
+
+
+
+
+
+
+
+
+
+
+");
+ sb.AppendLine("namespace AIRadioShowcase.Services;");
+ sb.AppendLine("public static class AudioCatalog");
+ sb.AppendLine("{");
+ sb.AppendLine(" public static readonly string[] Folders = new string[] {");
+
+ var audioDir = new DirectoryInfo(AudioRoot);
+ if (audioDir.Exists)
+ {
+ var dirs = audioDir.GetDirectories();
+ foreach (var dir in dirs)
+ {
+ sb.AppendLine(" \"" + dir.Name + "\",");
+ }
+ }
+ sb.AppendLine(" };");
+ sb.AppendLine();
+ sb.AppendLine(" public static readonly System.Collections.Generic.Dictionary FolderTracks = new()");
+ sb.AppendLine(" {");
+ if (audioDir.Exists)
+ {
+ var audioExtensions = new string[] { ".mp3", ".ogg", ".flac", ".m4a", ".wav", ".aac" };
+ foreach (var dir in audioDir.GetDirectories())
+ {
+ var audioFiles = dir.GetFiles()
+ .Where(f => audioExtensions.Contains(f.Extension.ToLowerInvariant()))
+ .GroupBy(f => Path.GetFileNameWithoutExtension(f.Name), StringComparer.OrdinalIgnoreCase)
+ .Select(g => g.OrderBy(f => Array.IndexOf(audioExtensions, f.Extension.ToLowerInvariant())).First().Name)
+ .OrderBy(n => n)
+ .ToArray();
+ sb.Append(" [\"" + dir.Name + "\"] = new string[] { ");
+ foreach (var f in audioFiles)
+ {
+ sb.Append("\"" + f + "\", ");
+ }
+ sb.AppendLine("},");
+ }
+ }
+ sb.AppendLine(" };");
+ sb.AppendLine("}");
+
+ var outDir = Path.GetDirectoryName(OutputFile);
+ if (!string.IsNullOrEmpty(outDir)) Directory.CreateDirectory(outDir);
+ File.WriteAllText(OutputFile, sb.ToString());
+]]>
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/AIRadioShowcase/Components/RadioCabinet.razor b/src/AIRadioShowcase/Components/RadioCabinet.razor
new file mode 100644
index 0000000..d8a2dae
--- /dev/null
+++ b/src/AIRadioShowcase/Components/RadioCabinet.razor
@@ -0,0 +1,148 @@
+@using AIRadioShowcase.Services
+@using AIRadioShowcase.Models
+@implements IDisposable
+@inject RadioStateService RadioState
+@inject RadioLibraryService Library
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ @if (!string.IsNullOrEmpty(RadioState.CurrentTrack?.CoverUrl))
+ {
+
+ }
+ else if (!string.IsNullOrEmpty(RadioState.TunedStation?.DefaultCoverUrl))
+ {
+
+ }
+ else
+ {
+
+ AI
+
+ }
+
+
+
+
+ @if (RadioState.TunedStation != null && RadioState.IsTuned)
+ {
+ @RadioState.TunedStation.Genre
+ }
+ else
+ {
+ NO CARRIER
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ showNativeControls = !showNativeControls" title="Toggle Native Browser Audio Bar">
+ @(showNativeControls ? "▲ HIDE BROWSER AUDIO" : "▼ NATIVE BROWSER AUDIO BAR")
+
+
+
+ @if (showNativeControls)
+ {
+
+ }
+ else
+ {
+
+ }
+
+
+@code {
+ private bool showNativeControls = false;
+
+ protected override void OnInitialized()
+ {
+ RadioState.OnStateChanged += HandleStateChanged;
+ }
+
+ private void HandleStateChanged()
+ {
+ InvokeAsync(StateHasChanged);
+ }
+
+ public void Dispose()
+ {
+ RadioState.OnStateChanged -= HandleStateChanged;
+ }
+}
diff --git a/src/AIRadioShowcase/Components/RadioKnobs.razor b/src/AIRadioShowcase/Components/RadioKnobs.razor
new file mode 100644
index 0000000..bfd9b90
--- /dev/null
+++ b/src/AIRadioShowcase/Components/RadioKnobs.razor
@@ -0,0 +1,128 @@
+@using AIRadioShowcase.Services
+@using AIRadioShowcase.Models
+@implements IDisposable
+@inject RadioStateService RadioState
+@inject RadioLibraryService Library
+
+
+
+
PRESETS
+
+ @for (int i = 0; i < Library.Stations.Count; i++)
+ {
+ var station = Library.Stations[i];
+ var isCurrent = RadioState.TunedStation?.Id == station.Id;
+ var num = i + 1;
+ RadioState.TuneToStationAsync(station)">
+ @num
+ @station.Name
+ @station.Frequency.ToString("0.0")
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+ @(RadioState.IsMuted ? "MUTED" : "VOL")
+
+
+
+
VOL: @((int)(RadioState.Volume * 100))%
+
+
+
+
+ AdjustFrequency(-0.1)" title="Fine Tune Down">-0.1
+
+ AdjustFrequency(0.1)" title="Fine Tune Up">+0.1
+
+
FM TUNER
+
+
+
+
+@code {
+ protected override void OnInitialized()
+ {
+ RadioState.OnStateChanged += HandleStateChanged;
+ }
+
+ private void HandleStateChanged()
+ {
+ InvokeAsync(StateHasChanged);
+ }
+
+ private async Task HandleVolumeChange(ChangeEventArgs e)
+ {
+ if (double.TryParse(e.Value?.ToString(), System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out double vol))
+ {
+ await RadioState.SetVolumeAsync(vol);
+ }
+ }
+
+ private async Task HandleFrequencyChange(ChangeEventArgs e)
+ {
+ if (double.TryParse(e.Value?.ToString(), System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out double freq))
+ {
+ await RadioState.SetFrequencyAsync(freq);
+ }
+ }
+
+ private async Task AdjustFrequency(double delta)
+ {
+ await RadioState.SetFrequencyAsync(RadioState.CurrentFrequency + delta);
+ }
+
+ public void Dispose()
+ {
+ RadioState.OnStateChanged -= HandleStateChanged;
+ }
+}
diff --git a/src/AIRadioShowcase/Components/TuningDial.razor b/src/AIRadioShowcase/Components/TuningDial.razor
new file mode 100644
index 0000000..38b99ac
--- /dev/null
+++ b/src/AIRadioShowcase/Components/TuningDial.razor
@@ -0,0 +1,95 @@
+@using AIRadioShowcase.Services
+@using AIRadioShowcase.Models
+@implements IDisposable
+@inject RadioStateService RadioState
+@inject RadioLibraryService Library
+
+
+
+
+
+
+ @for (int f = 88; f <= 108; f += 2)
+ {
+ var pos = GetPercentageForFreq(f);
+
+ }
+
+ @for (double f = 88.5; f < 108; f += 1.0)
+ {
+ var pos = GetPercentageForFreq(f);
+
+ }
+
+ @foreach (var station in Library.Stations)
+ {
+ var pos = GetPercentageForFreq(station.Frequency);
+ var isSelected = RadioState.TunedStation?.Id == station.Id;
+
RadioState.TuneToStationAsync(station)">
+
+ @station.Name
+
+ }
+
+
+
+
+
+
+
SIGNAL
+
+
@RadioState.CurrentFrequency.ToString("0.0") MHz
+
+
+
+@code {
+ protected override void OnInitialized()
+ {
+ RadioState.OnStateChanged += HandleStateChanged;
+ }
+
+ private void HandleStateChanged()
+ {
+ InvokeAsync(StateHasChanged);
+ }
+
+ private double GetPercentageForFreq(double freq)
+ {
+ double min = 88.0;
+ double max = 108.0;
+ double clamped = Math.Clamp(freq, min, max);
+ return ((clamped - min) / (max - min)) * 100.0;
+ }
+
+ private async Task HandleDialClick(MouseEventArgs e)
+ {
+ }
+
+ public void Dispose()
+ {
+ RadioState.OnStateChanged -= HandleStateChanged;
+ }
+}
diff --git a/src/AIRadioShowcase/Components/VfdDisplay.razor b/src/AIRadioShowcase/Components/VfdDisplay.razor
new file mode 100644
index 0000000..a318009
--- /dev/null
+++ b/src/AIRadioShowcase/Components/VfdDisplay.razor
@@ -0,0 +1,130 @@
+@using AIRadioShowcase.Services
+@using AIRadioShowcase.Models
+@implements IDisposable
+@inject RadioStateService RadioState
+
+
+
+
+
+
+
+ ●
+ @if (RadioState.IsPoweredOn)
+ {
+ if (RadioState.IsTuned && RadioState.TunedStation != null)
+ {
+ @(RadioState.IsPlaying ? "▶ PLAYING" : "⏸ PAUSED") // @RadioState.TunedStation.Name
+ }
+ else
+ {
+ SEEKING... [STATIC]
+ }
+ }
+ else
+ {
+ STANDBY
+ }
+
+
+ @if (RadioState.Duration > 0)
+ {
+ @FormatTime(RadioState.CurrentTime) / @FormatTime(RadioState.Duration)
+ }
+ else
+ {
+ --:-- / --:--
+ }
+
+
+
+
+
+
+
+
+
+ @if (RadioState.IsPoweredOn && RadioState.IsTuned && !string.IsNullOrWhiteSpace(RadioState.CurrentLyrics))
+ {
+
@RadioState.CurrentLyrics
+ }
+ else if (RadioState.IsPoweredOn && !RadioState.IsTuned)
+ {
+
+
░░▒▒▓▓ NO CARRIER ▓▓▒▒░░
+
Tune the frequency to lock onto a station.
+
+ }
+ else
+ {
+
+ }
+
+
+
+
+@code {
+ protected override void OnInitialized()
+ {
+ RadioState.OnStateChanged += HandleStateChanged;
+ }
+
+ private void HandleStateChanged()
+ {
+ InvokeAsync(StateHasChanged);
+ }
+
+ private string FormatTime(double seconds)
+ {
+ var ts = TimeSpan.FromSeconds(seconds);
+ return $"{(int)ts.TotalMinutes:D2}:{ts.Seconds:D2}";
+ }
+
+ public void Dispose()
+ {
+ RadioState.OnStateChanged -= HandleStateChanged;
+ }
+}
diff --git a/src/AIRadioShowcase/Models/RadioStation.cs b/src/AIRadioShowcase/Models/RadioStation.cs
new file mode 100644
index 0000000..3e5c116
--- /dev/null
+++ b/src/AIRadioShowcase/Models/RadioStation.cs
@@ -0,0 +1,14 @@
+namespace AIRadioShowcase.Models;
+
+public class RadioStation
+{
+ public string Id { get; set; } = string.Empty;
+ public string Name { get; set; } = string.Empty;
+ public double Frequency { get; set; } // e.g. 94.2 MHz
+ public string Genre { get; set; } = string.Empty;
+ public string Description { get; set; } = string.Empty;
+ public string FolderName { get; set; } = string.Empty;
+ public string? DefaultCoverUrl { get; set; }
+ public string ThemeColor { get; set; } = "#ff9900";
+ public List Tracks { get; set; } = new();
+}
diff --git a/src/AIRadioShowcase/Models/RadioTrack.cs b/src/AIRadioShowcase/Models/RadioTrack.cs
new file mode 100644
index 0000000..9608977
--- /dev/null
+++ b/src/AIRadioShowcase/Models/RadioTrack.cs
@@ -0,0 +1,16 @@
+namespace AIRadioShowcase.Models;
+
+public class RadioTrack
+{
+ public string Id { get; set; } = Guid.NewGuid().ToString();
+ public string Title { get; set; } = "Unknown Track";
+ public string Artist { get; set; } = "AI Radio Generator";
+ public string AudioUrl { get; set; } = string.Empty;
+ public string? LyricsUrl { get; set; }
+ public string? LyricsContent { get; set; }
+ public string? CoverUrl { get; set; }
+ public string? Prompt { get; set; }
+ public string? Style { get; set; }
+ public double DurationSeconds { get; set; } = 0;
+ public string FileName { get; set; } = string.Empty;
+}
diff --git a/src/AIRadioShowcase/Pages/Home.razor b/src/AIRadioShowcase/Pages/Home.razor
index 9001e0b..b156ac7 100644
--- a/src/AIRadioShowcase/Pages/Home.razor
+++ b/src/AIRadioShowcase/Pages/Home.razor
@@ -1,7 +1,19 @@
@page "/"
+@inject RadioStateService RadioState
+@inject RadioLibraryService Library
-Home
+AI Retro Radio 24/7
-Hello, world!
+
+
+
-Welcome to your new app.
+@code {
+ protected override async Task OnAfterRenderAsync(bool firstRender)
+ {
+ if (firstRender)
+ {
+ await RadioState.InitializeAsync();
+ }
+ }
+}
diff --git a/src/AIRadioShowcase/Program.cs b/src/AIRadioShowcase/Program.cs
index 8c1814e..d69c316 100644
--- a/src/AIRadioShowcase/Program.cs
+++ b/src/AIRadioShowcase/Program.cs
@@ -1,4 +1,5 @@
using AIRadioShowcase;
+using AIRadioShowcase.Services;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
@@ -7,5 +8,7 @@ builder.RootComponents.Add("#app");
builder.RootComponents.Add("head::after");
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
+builder.Services.AddScoped();
+builder.Services.AddScoped();
await builder.Build().RunAsync();
diff --git a/src/AIRadioShowcase/Services/AudioGenerator.cs b/src/AIRadioShowcase/Services/AudioGenerator.cs
new file mode 100644
index 0000000..1c6d8f1
--- /dev/null
+++ b/src/AIRadioShowcase/Services/AudioGenerator.cs
@@ -0,0 +1,104 @@
+using System.Text;
+
+namespace AIRadioShowcase.Services;
+
+public static class AudioGenerator
+{
+ public static byte[] GenerateSynthWav(string style, double durationSeconds = 15.0, int sampleRate = 44100)
+ {
+ int numSamples = (int)(durationSeconds * sampleRate);
+ short numChannels = 2;
+ short bitsPerSample = 16;
+ int byteRate = sampleRate * numChannels * (bitsPerSample / 8);
+ short blockAlign = (short)(numChannels * (bitsPerSample / 8));
+ int subChunk2Size = numSamples * blockAlign;
+ int chunkSize = 36 + subChunk2Size;
+
+ using var ms = new MemoryStream();
+ using var bw = new BinaryWriter(ms);
+
+ // RIFF header
+ bw.Write(Encoding.ASCII.GetBytes("RIFF"));
+ bw.Write(chunkSize);
+ bw.Write(Encoding.ASCII.GetBytes("WAVE"));
+
+ // fmt chunk
+ bw.Write(Encoding.ASCII.GetBytes("fmt "));
+ bw.Write(16); // Subchunk1Size (16 for PCM)
+ bw.Write((short)1); // AudioFormat (1 = PCM)
+ bw.Write(numChannels);
+ bw.Write(sampleRate);
+ bw.Write(byteRate);
+ bw.Write(blockAlign);
+ bw.Write(bitsPerSample);
+
+ // data chunk
+ bw.Write(Encoding.ASCII.GetBytes("data"));
+ bw.Write(subChunk2Size);
+
+ double[] scale = style switch
+ {
+ "lofi" => new[] { 196.0, 246.94, 293.66, 349.23, 392.0, 440.0 },
+ "cyber_rock" => new[] { 110.0, 130.81, 146.83, 164.81, 174.61, 220.0 },
+ "ambient" => new[] { 130.81, 196.0, 261.63, 329.63, 392.0 },
+ _ => new[] { 220.0, 261.63, 293.66, 329.63, 392.0, 440.0, 523.25 }
+ };
+
+ var rnd = new Random(42);
+
+ for (int i = 0; i < numSamples; i++)
+ {
+ double t = (double)i / sampleRate;
+ double sample = 0.0;
+
+ if (style == "synthwave")
+ {
+ int noteIdx = ((int)(t * 6)) % scale.Length;
+ double freq = scale[noteIdx];
+ double bass = Math.Sin(2 * Math.PI * (freq / 2.0) * t) * 0.35;
+
+ double leadFreq = scale[((int)(t * 3)) % scale.Length] * 2.0;
+ double lead = Math.Sin(2 * Math.PI * leadFreq * t) * 0.25;
+
+ double beatFrac = (t * 2) - Math.Floor(t * 2);
+ double kick = Math.Sin(2 * Math.PI * (100 * Math.Exp(-beatFrac * 15)) * beatFrac) * Math.Exp(-beatFrac * 5) * 0.4;
+
+ sample = bass + lead + kick;
+ }
+ else if (style == "lofi")
+ {
+ int chordIdx = ((int)(t / 2.5)) % 4;
+ double f1 = scale[chordIdx % scale.Length];
+ double f2 = scale[(chordIdx + 2) % scale.Length];
+ double chords = (Math.Sin(2 * Math.PI * f1 * t) + Math.Sin(2 * Math.PI * f2 * t)) * 0.22;
+ double crackle = (rnd.NextDouble() - 0.5) * 0.04;
+ sample = chords + crackle;
+ }
+ else if (style == "cyber_rock")
+ {
+ int noteIdx = ((int)(t * 4)) % scale.Length;
+ double freq = scale[noteIdx];
+ double raw = Math.Sin(2 * Math.PI * freq * t) * 2.0;
+ double dist = Math.Max(-0.6, Math.Min(0.6, raw)) * 0.4;
+ double beat = Math.Sin(2 * Math.PI * 55 * t) * (((int)(t * 4)) % 2) * 0.3;
+ sample = dist + beat;
+ }
+ else // ambient
+ {
+ double f1 = 110.0 + Math.Sin(t * 0.2) * 5.0;
+ double f2 = 165.0 + Math.Cos(t * 0.15) * 4.0;
+ double pad = (Math.Sin(2 * Math.PI * f1 * t) + Math.Sin(2 * Math.PI * f2 * t)) * 0.3;
+ sample = pad;
+ }
+
+ sample = Math.Max(-0.95, Math.Min(0.95, sample));
+ short shortVal = (short)(sample * 32767);
+
+ bw.Write(shortVal);
+ bw.Write(shortVal);
+ }
+
+ bw.Flush();
+ return ms.ToArray();
+ }
+}
diff --git a/src/AIRadioShowcase/Services/RadioLibraryService.cs b/src/AIRadioShowcase/Services/RadioLibraryService.cs
new file mode 100644
index 0000000..9e55514
--- /dev/null
+++ b/src/AIRadioShowcase/Services/RadioLibraryService.cs
@@ -0,0 +1,234 @@
+using System.Globalization;
+using AIRadioShowcase.Models;
+
+namespace AIRadioShowcase.Services;
+
+public class RadioLibraryService
+{
+ private readonly HttpClient _http;
+ private List _stations = new();
+
+ public RadioLibraryService(HttpClient http)
+ {
+ _http = http;
+ }
+
+ public IReadOnlyList Stations => _stations;
+
+ public async Task> LoadStationsAsync()
+ {
+ var folders = AudioCatalog.Folders;
+ var loaded = new List();
+ int index = 0;
+
+ foreach (var folder in folders)
+ {
+ var station = await LoadStationFromFolderAsync(folder, index++);
+ loaded.Add(station);
+ }
+
+ _stations = loaded;
+ return _stations;
+ }
+
+ private async Task LoadStationFromFolderAsync(string folder, int index)
+ {
+ var formattedName = FormatStationName(folder);
+ var station = new RadioStation
+ {
+ Id = folder,
+ Name = formattedName,
+ FolderName = folder,
+ Frequency = GetDefaultFrequency(index),
+ Genre = FormatGenre(folder),
+ Description = $"{formattedName} AI Broadcast Transmission.",
+ DefaultCoverUrl = $"images/{folder}_cover.svg",
+ ThemeColor = GetThemeColor(folder, index)
+ };
+
+ // Try reading station.txt for custom description / metadata
+ try
+ {
+ var stationTxt = await _http.GetStringAsync($"audio/{folder}/station.txt");
+ if (!string.IsNullOrWhiteSpace(stationTxt))
+ {
+ ParseStationTxt(station, stationTxt);
+ }
+ }
+ catch
+ {
+ // station.txt is optional
+ }
+
+ // Dynamically discovered tracks from audio folder
+ if (AudioCatalog.FolderTracks.TryGetValue(folder, out var fileNames) && fileNames != null && fileNames.Length > 0)
+ {
+ foreach (var fileName in fileNames)
+ {
+ var track = BuildTrack(folder, fileName, station.DefaultCoverUrl);
+ station.Tracks.Add(track);
+ }
+ }
+
+ // If no tracks found, add a fallback track entry
+ if (station.Tracks.Count == 0)
+ {
+ station.Tracks.Add(BuildTrack(folder, "event_horizon.mp3", station.DefaultCoverUrl));
+ }
+
+ return station;
+ }
+
+ private void ParseStationTxt(RadioStation station, string content)
+ {
+ var lines = content.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
+ var descriptionLines = new List();
+
+ foreach (var rawLine in lines)
+ {
+ var line = rawLine.Trim();
+ if (line.StartsWith("Genre:", StringComparison.OrdinalIgnoreCase))
+ {
+ station.Genre = line.Substring("Genre:".Length).Trim();
+ }
+ else if (line.StartsWith("Freq:", StringComparison.OrdinalIgnoreCase) || line.StartsWith("Frequency:", StringComparison.OrdinalIgnoreCase))
+ {
+ var valStr = line.Contains(':') ? line.Split(':')[1].Trim() : "";
+ valStr = valStr.Replace("MHz", "", StringComparison.OrdinalIgnoreCase).Trim();
+ if (double.TryParse(valStr, NumberStyles.Any, CultureInfo.InvariantCulture, out double freq))
+ {
+ station.Frequency = freq;
+ }
+ }
+ else if (line.StartsWith("Color:", StringComparison.OrdinalIgnoreCase) || line.StartsWith("ThemeColor:", StringComparison.OrdinalIgnoreCase))
+ {
+ station.ThemeColor = line.Contains(':') ? line.Split(':')[1].Trim() : station.ThemeColor;
+ }
+ else if (line.StartsWith("Name:", StringComparison.OrdinalIgnoreCase))
+ {
+ station.Name = line.Substring("Name:".Length).Trim();
+ }
+ else
+ {
+ descriptionLines.Add(line);
+ }
+ }
+
+ if (descriptionLines.Count > 0)
+ {
+ station.Description = string.Join(" ", descriptionLines);
+ }
+ }
+
+ private RadioTrack BuildTrack(string folder, string fileName, string? defaultCover)
+ {
+ var nameWithoutExt = Path.GetFileNameWithoutExtension(fileName);
+ ParseArtistAndTitle(nameWithoutExt, out string artist, out string title);
+
+ var track = new RadioTrack
+ {
+ Title = title,
+ Artist = artist,
+ FileName = fileName,
+ AudioUrl = $"audio/{folder}/{fileName}",
+ LyricsUrl = $"audio/{folder}/{nameWithoutExt}.txt",
+ CoverUrl = defaultCover
+ };
+
+ return track;
+ }
+
+ private void ParseArtistAndTitle(string nameWithoutExt, out string artist, out string title)
+ {
+ if (nameWithoutExt.Contains(" - "))
+ {
+ var parts = nameWithoutExt.Split(new[] { " - " }, 2, StringSplitOptions.TrimEntries);
+ artist = parts[0];
+ title = FormatName(parts[1]);
+ }
+ else
+ {
+ artist = "AI Synthesizer";
+ title = FormatName(nameWithoutExt);
+ }
+ }
+
+ private string FormatName(string raw)
+ {
+ var cleaned = raw.Replace('_', ' ').Replace('-', ' ').Trim();
+ return CultureInfo.CurrentCulture.TextInfo.ToTitleCase(cleaned);
+ }
+
+ private string FormatStationName(string folderName)
+ {
+ // Replace underscore with space
+ var clean = folderName.Replace('_', ' ').Trim();
+ return CultureInfo.CurrentCulture.TextInfo.ToTitleCase(clean);
+ }
+
+ private string FormatGenre(string folderName)
+ {
+ return folderName.ToLowerInvariant() switch
+ {
+ "synthwave" => "Synthwave / Outrun",
+ "lofi" => "Lo-Fi / Chill Beats",
+ "cyber_rock" => "Cyber Rock / Industrial",
+ "ambient" => "Dark Ambient / Space Drone",
+ _ => FormatStationName(folderName)
+ };
+ }
+
+ private double GetDefaultFrequency(int index)
+ {
+ return index switch
+ {
+ 0 => 94.2,
+ 1 => 98.6,
+ 2 => 103.8,
+ 3 => 89.5,
+ _ => Math.Round(88.5 + (index * 3.2), 1)
+ };
+ }
+
+ private string GetThemeColor(string folderName, int index)
+ {
+ return folderName.ToLowerInvariant() switch
+ {
+ "synthwave" => "#ff007f",
+ "lofi" => "#00f0ff",
+ "cyber_rock" => "#ffdd00",
+ "ambient" => "#39ff14",
+ _ => (index % 4) switch
+ {
+ 0 => "#ff007f",
+ 1 => "#00f0ff",
+ 2 => "#ffdd00",
+ _ => "#39ff14"
+ }
+ };
+ }
+
+ public async Task LoadLyricsAsync(RadioTrack track)
+ {
+ if (!string.IsNullOrEmpty(track.LyricsContent))
+ {
+ return track.LyricsContent;
+ }
+
+ if (string.IsNullOrEmpty(track.LyricsUrl))
+ {
+ return "No lyrics file found for this track.";
+ }
+
+ try
+ {
+ var text = await _http.GetStringAsync(track.LyricsUrl);
+ track.LyricsContent = text;
+ return text;
+ }
+ catch
+ {
+ return "No transmission log / lyrics available.";
+ }
+ }
+}
diff --git a/src/AIRadioShowcase/Services/RadioStateService.cs b/src/AIRadioShowcase/Services/RadioStateService.cs
new file mode 100644
index 0000000..68e76c8
--- /dev/null
+++ b/src/AIRadioShowcase/Services/RadioStateService.cs
@@ -0,0 +1,380 @@
+using AIRadioShowcase.Models;
+using Microsoft.JSInterop;
+
+namespace AIRadioShowcase.Services;
+
+public class RadioStateService : IDisposable
+{
+ private readonly IJSRuntime _js;
+ private readonly RadioLibraryService _library;
+ private DotNetObjectReference? _dotNetRef;
+ private bool _isInitialized = false;
+
+ public event Action? OnStateChanged;
+
+ public bool IsPoweredOn { get; private set; } = true;
+ public double CurrentFrequency { get; private set; } = 94.2;
+ public RadioStation? TunedStation { get; private set; }
+ public double SignalStrength { get; private set; } = 1.0;
+ public bool IsTuned => SignalStrength >= 0.75;
+ public bool IsStereo => SignalStrength >= 0.90;
+
+ public bool IsPlaying { get; private set; } = false;
+ public double Volume { get; private set; } = 0.8;
+ public bool IsMuted { get; private set; } = false;
+
+ public int CurrentTrackIndex { get; private set; } = 0;
+ public RadioTrack? CurrentTrack => TunedStation?.Tracks.Count > CurrentTrackIndex && CurrentTrackIndex >= 0
+ ? TunedStation.Tracks[CurrentTrackIndex]
+ : null;
+
+ public double CurrentTime { get; private set; } = 0;
+ public double Duration { get; private set; } = 0;
+ public string CurrentLyrics { get; private set; } = string.Empty;
+
+ public RadioStateService(IJSRuntime js, RadioLibraryService library)
+ {
+ _js = js;
+ _library = library;
+ }
+
+ public async Task InitializeAsync()
+ {
+ if (_isInitialized) return;
+ _isInitialized = true;
+
+ try
+ {
+ var stations = await _library.LoadStationsAsync();
+ if (stations.Count > 0)
+ {
+ var initial = stations.FirstOrDefault(s => Math.Abs(s.Frequency - CurrentFrequency) < 0.3) ?? stations[0];
+ CurrentFrequency = initial.Frequency;
+ UpdateTuning();
+ }
+
+ _dotNetRef = DotNetObjectReference.Create(this);
+ await _js.InvokeVoidAsync("retroRadio.init", _dotNetRef);
+
+ if (CurrentTrack != null)
+ {
+ await LoadTrackDataAsync();
+ }
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Radio initialization error: {ex.Message}");
+ }
+
+ NotifyStateChanged();
+ }
+
+ public async Task TogglePowerAsync()
+ {
+ IsPoweredOn = !IsPoweredOn;
+ try
+ {
+ if (!IsPoweredOn)
+ {
+ IsPlaying = false;
+ await _js.InvokeVoidAsync("retroRadio.setPower", false);
+ }
+ else
+ {
+ await _js.InvokeVoidAsync("retroRadio.setPower", true);
+ if (IsTuned && CurrentTrack != null)
+ {
+ await PlayCurrentTrackAsync();
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Power toggle error: {ex.Message}");
+ }
+ NotifyStateChanged();
+ }
+
+ [JSInvokable]
+ public async Task SetFrequencyAsync(double frequency)
+ {
+ frequency = Math.Clamp(Math.Round(frequency, 1), 88.0, 108.0);
+ CurrentFrequency = frequency;
+ var prevStation = TunedStation;
+ UpdateTuning();
+
+ try
+ {
+ await _js.InvokeVoidAsync("retroRadio.updateFrequency", CurrentFrequency, SignalStrength);
+
+ if (IsPoweredOn)
+ {
+ if (TunedStation != null && TunedStation != prevStation)
+ {
+ CurrentTrackIndex = 0;
+ await LoadTrackDataAsync();
+ await PlayCurrentTrackAsync();
+ }
+ else if (TunedStation == null)
+ {
+ await _js.InvokeVoidAsync("retroRadio.fadeToStatic");
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Frequency change error: {ex.Message}");
+ }
+
+ NotifyStateChanged();
+ }
+
+ public async Task TuneToStationAsync(RadioStation station)
+ {
+ CurrentFrequency = station.Frequency;
+ UpdateTuning();
+ CurrentTrackIndex = 0;
+ await LoadTrackDataAsync();
+ try
+ {
+ await _js.InvokeVoidAsync("retroRadio.updateFrequency", CurrentFrequency, SignalStrength);
+ if (IsPoweredOn)
+ {
+ await PlayCurrentTrackAsync();
+ }
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Tune station error: {ex.Message}");
+ }
+ NotifyStateChanged();
+ }
+
+ public async Task TogglePlayPauseAsync()
+ {
+ if (!IsPoweredOn) return;
+
+ if (IsPlaying)
+ {
+ await PauseAsync();
+ }
+ else
+ {
+ await PlayAsync();
+ }
+ }
+
+ [JSInvokable]
+ public async Task PlayAsync()
+ {
+ if (!IsPoweredOn) return;
+
+ try
+ {
+ if (CurrentTrack == null)
+ {
+ if (TunedStation != null && TunedStation.Tracks.Count > 0)
+ {
+ CurrentTrackIndex = 0;
+ await LoadTrackDataAsync();
+ }
+ else
+ {
+ var firstStation = _library.Stations.FirstOrDefault(s => s.Tracks.Count > 0);
+ if (firstStation != null)
+ {
+ await TuneToStationAsync(firstStation);
+ return;
+ }
+ }
+ }
+
+ if (CurrentTrack != null)
+ {
+ await PlayCurrentTrackAsync();
+ }
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Play error: {ex.Message}");
+ }
+ NotifyStateChanged();
+ }
+
+ [JSInvokable]
+ public async Task PauseAsync()
+ {
+ IsPlaying = false;
+ try
+ {
+ await _js.InvokeVoidAsync("retroRadio.pauseTrack");
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Pause error: {ex.Message}");
+ }
+ NotifyStateChanged();
+ }
+
+ [JSInvokable]
+ public async Task NextTrackAsync()
+ {
+ if (TunedStation == null || TunedStation.Tracks.Count == 0) return;
+
+ CurrentTrackIndex = (CurrentTrackIndex + 1) % TunedStation.Tracks.Count;
+ CurrentTime = 0;
+ await LoadTrackDataAsync();
+ if (IsPoweredOn && IsPlaying)
+ {
+ await PlayCurrentTrackAsync();
+ }
+ NotifyStateChanged();
+ }
+
+ [JSInvokable]
+ public async Task PreviousTrackAsync()
+ {
+ if (TunedStation == null || TunedStation.Tracks.Count == 0) return;
+
+ CurrentTrackIndex = (CurrentTrackIndex - 1 + TunedStation.Tracks.Count) % TunedStation.Tracks.Count;
+ CurrentTime = 0;
+ await LoadTrackDataAsync();
+ if (IsPoweredOn && IsPlaying)
+ {
+ await PlayCurrentTrackAsync();
+ }
+ NotifyStateChanged();
+ }
+
+ [JSInvokable]
+ public async Task SetVolumeAsync(double volume)
+ {
+ Volume = Math.Clamp(volume, 0.0, 1.0);
+ IsMuted = Volume <= 0.01;
+ try
+ {
+ await _js.InvokeVoidAsync("retroRadio.setVolume", Volume);
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Volume error: {ex.Message}");
+ }
+ NotifyStateChanged();
+ }
+
+ public async Task ToggleMuteAsync()
+ {
+ IsMuted = !IsMuted;
+ var effectiveVol = IsMuted ? 0 : Volume;
+ try
+ {
+ await _js.InvokeVoidAsync("retroRadio.setVolume", effectiveVol);
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Mute error: {ex.Message}");
+ }
+ NotifyStateChanged();
+ }
+
+ [JSInvokable]
+ public async Task SeekAsync(double seconds)
+ {
+ CurrentTime = seconds;
+ try
+ {
+ await _js.InvokeVoidAsync("retroRadio.seek", seconds);
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Seek error: {ex.Message}");
+ }
+ NotifyStateChanged();
+ }
+
+ private void UpdateTuning()
+ {
+ RadioStation? closestStation = null;
+ double minDistance = double.MaxValue;
+
+ foreach (var station in _library.Stations)
+ {
+ double dist = Math.Abs(station.Frequency - CurrentFrequency);
+ if (dist < minDistance)
+ {
+ minDistance = dist;
+ closestStation = station;
+ }
+ }
+
+ if (minDistance <= 0.35 && closestStation != null)
+ {
+ TunedStation = closestStation;
+ SignalStrength = Math.Max(0.0, 1.0 - (minDistance / 0.35));
+ }
+ else
+ {
+ TunedStation = null;
+ SignalStrength = 0.0;
+ }
+ }
+
+ private async Task LoadTrackDataAsync()
+ {
+ if (CurrentTrack != null)
+ {
+ CurrentLyrics = await _library.LoadLyricsAsync(CurrentTrack);
+ try
+ {
+ await _js.InvokeVoidAsync("retroRadio.updateTrackInfo", CurrentTrack.Title, CurrentTrack.Artist, CurrentTrack.CoverUrl, CurrentLyrics);
+ }
+ catch {}
+ }
+ }
+
+ private async Task PlayCurrentTrackAsync()
+ {
+ if (CurrentTrack == null) return;
+ await LoadTrackDataAsync();
+ IsPlaying = true;
+ try
+ {
+ await _js.InvokeVoidAsync("retroRadio.playTrack", CurrentTrack.AudioUrl, 0);
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Play track error: {ex.Message}");
+ }
+ }
+
+ [JSInvokable]
+ public async Task OnTimeUpdate(double currentTime, double duration)
+ {
+ CurrentTime = currentTime;
+ Duration = duration;
+ NotifyStateChanged();
+ }
+
+ [JSInvokable]
+ public async Task OnTrackEnded()
+ {
+ await NextTrackAsync();
+ }
+
+ [JSInvokable]
+ public void OnAudioStateChanged(bool isPlaying)
+ {
+ IsPlaying = isPlaying;
+ NotifyStateChanged();
+ }
+
+ private void NotifyStateChanged()
+ {
+ OnStateChanged?.Invoke();
+ }
+
+ public void Dispose()
+ {
+ _dotNetRef?.Dispose();
+ }
+}
diff --git a/src/AIRadioShowcase/_Imports.razor b/src/AIRadioShowcase/_Imports.razor
index d0690ee..9e42be6 100644
--- a/src/AIRadioShowcase/_Imports.razor
+++ b/src/AIRadioShowcase/_Imports.razor
@@ -1,4 +1,4 @@
-@using System.Net.Http
+@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@@ -8,3 +8,6 @@
@using Microsoft.JSInterop
@using AIRadioShowcase
@using AIRadioShowcase.Layout
+@using AIRadioShowcase.Models
+@using AIRadioShowcase.Services
+@using AIRadioShowcase.Components
diff --git a/src/AIRadioShowcase/wwwroot/audio/synthwave/electro.mp3 b/src/AIRadioShowcase/wwwroot/audio/synthwave/electro.mp3
new file mode 100644
index 0000000..e3fa38d
Binary files /dev/null and b/src/AIRadioShowcase/wwwroot/audio/synthwave/electro.mp3 differ
diff --git a/src/AIRadioShowcase/wwwroot/audio/synthwave/electro.txt b/src/AIRadioShowcase/wwwroot/audio/synthwave/electro.txt
new file mode 100644
index 0000000..bba3c59
--- /dev/null
+++ b/src/AIRadioShowcase/wwwroot/audio/synthwave/electro.txt
@@ -0,0 +1,18 @@
+this is text
+this is text
+this is text
+this is text
+this is text
+this is text
+this is text
+this is text
+this is text
+this is text
+this is text
+this is text
+this is text
+this is text
+this is text
+this is text
+this is text
+this is text
diff --git a/src/AIRadioShowcase/wwwroot/audio/synthwave/station.txt b/src/AIRadioShowcase/wwwroot/audio/synthwave/station.txt
new file mode 100644
index 0000000..e73d67d
--- /dev/null
+++ b/src/AIRadioShowcase/wwwroot/audio/synthwave/station.txt
@@ -0,0 +1,3 @@
+Neon nights, analog synthesizers
+Genre: Synthwave / Outrun
+Freq: 94.2
diff --git a/src/AIRadioShowcase/wwwroot/css/radio.css b/src/AIRadioShowcase/wwwroot/css/radio.css
new file mode 100644
index 0000000..9945390
--- /dev/null
+++ b/src/AIRadioShowcase/wwwroot/css/radio.css
@@ -0,0 +1,950 @@
+/* ==========================================================================
+ RETRO-FUTURISTIC AI RADIO STYLING (COMPACT & CENTERED)
+ ========================================================================== */
+
+:root {
+ --color-chassis-wood: #1e140d;
+ --color-chassis-dark: #121418;
+ --color-faceplate: #1a1d24;
+ --color-faceplate-metal: #232730;
+ --color-accent-amber: #ff9900;
+ --color-accent-vfd: #33ffaa;
+ --color-vfd-bg: #03150e;
+ --color-vfd-dim: rgba(51, 255, 170, 0.4);
+ --color-vfd-glow: rgba(51, 255, 170, 0.8);
+ --color-needle: #ff2a2a;
+ --color-needle-glow: rgba(255, 42, 42, 0.9);
+ --font-display: 'Orbitron', 'Rajdhani', sans-serif;
+ --font-mono: 'Share Tech Mono', 'Courier New', monospace;
+}
+
+html, body {
+ margin: 0;
+ padding: 0;
+ height: 100%;
+ width: 100%;
+ background: radial-gradient(circle at 50% 40%, #151821 0%, #07080a 100%);
+ color: #e0e6ed;
+ font-family: var(--font-mono);
+ overflow: hidden;
+}
+
+/* Perfect Center Wrapper */
+.radio-app-wrapper {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100vw;
+ height: 100vh;
+ padding: 10px;
+ box-sizing: border-box;
+}
+
+/* Radio Cabinet (Compact Body) */
+.radio-cabinet {
+ width: 100%;
+ max-width: 680px;
+ background: linear-gradient(180deg, #2c1a0e 0%, #170d06 100%);
+ border-radius: 14px;
+ box-shadow:
+ 0 20px 50px rgba(0, 0, 0, 0.9),
+ 0 0 0 6px #100804,
+ 0 0 0 8px #422917,
+ inset 0 2px 3px rgba(255, 255, 255, 0.15),
+ inset 0 -3px 6px rgba(0, 0, 0, 0.7);
+ padding: 10px 12px;
+ position: relative;
+ box-sizing: border-box;
+ transition: all 0.3s ease;
+}
+
+.radio-cabinet.power-off {
+ filter: brightness(0.65);
+}
+
+/* Cabinet Trims */
+.cabinet-top-trim, .cabinet-bottom-trim {
+ height: 8px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ position: relative;
+}
+
+.chassis-screws {
+ display: flex;
+ gap: 30px;
+ margin: 0 auto;
+}
+
+.screw {
+ width: 6px;
+ height: 6px;
+ background: radial-gradient(circle, #888 0%, #333 100%);
+ border-radius: 50%;
+ box-shadow: inset 0 1px 1px #fff, 0 1px 2px #000;
+ display: inline-block;
+}
+
+/* Front Faceplate (Brushed Metal) */
+.cabinet-faceplate {
+ background: linear-gradient(180deg, #252a33 0%, #1c2027 50%, #15181e 100%);
+ border: 2px solid #363c48;
+ border-radius: 10px;
+ padding: 12px 14px;
+ box-shadow:
+ inset 0 1px 2px rgba(255, 255, 255, 0.15),
+ inset 0 0 15px rgba(0, 0, 0, 0.6),
+ 0 3px 8px rgba(0, 0, 0, 0.5);
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+/* Header & Brand Badge */
+.faceplate-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ border-bottom: 1px solid #2d333f;
+ padding-bottom: 8px;
+ flex-wrap: nowrap;
+ gap: 8px;
+}
+
+.brand-badge {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.brand-logo {
+ font-size: 18px;
+ color: var(--color-accent-amber);
+ text-shadow: 0 0 8px rgba(255, 153, 0, 0.8);
+}
+
+.brand-name {
+ font-family: var(--font-display);
+ font-size: 1.05rem;
+ font-weight: 900;
+ letter-spacing: 2px;
+ color: #f0f3f8;
+ display: block;
+ line-height: 1.1;
+}
+
+.brand-sub {
+ font-size: 0.55rem;
+ letter-spacing: 1.5px;
+ color: #838e9e;
+ display: block;
+}
+
+/* VU Meters (Compact) */
+.vu-meters-cluster {
+ display: flex;
+ gap: 8px;
+}
+
+.vu-meter {
+ background: #0d1015;
+ border: 1px solid #363d4a;
+ border-radius: 4px;
+ padding: 3px 6px 2px 6px;
+ width: 58px;
+ text-align: center;
+ box-shadow: inset 0 2px 4px #000;
+}
+
+.vu-face {
+ height: 24px;
+ background: radial-gradient(ellipse at bottom, #ffe699 0%, #d4a036 80%, #996e1a 100%);
+ border-radius: 3px 3px 1px 1px;
+ position: relative;
+ overflow: hidden;
+}
+
+.vu-scale-art {
+ display: flex;
+ justify-content: space-between;
+ font-size: 0.45rem;
+ font-weight: bold;
+ color: #222;
+ padding: 1px 2px;
+}
+
+.vu-scale-art .vu-max {
+ color: #b30000;
+}
+
+.vu-needle-pivot {
+ position: absolute;
+ bottom: -4px;
+ left: 50%;
+ transform: translateX(-50%);
+ width: 6px;
+ height: 6px;
+ background: #111;
+ border-radius: 50%;
+}
+
+.vu-needle {
+ position: absolute;
+ bottom: 2px;
+ left: 2px;
+ width: 1px;
+ height: 20px;
+ background: #111;
+ transform-origin: bottom center;
+ transform: rotate(-35deg);
+ transition: transform 0.05s ease-out;
+}
+
+.vu-label {
+ font-size: 0.5rem;
+ color: #8c96a5;
+ margin-top: 1px;
+ display: block;
+}
+
+/* Status Pills */
+.broadcast-status-pills {
+ display: flex;
+ gap: 6px;
+}
+
+.pill-badge {
+ background: #13171d;
+ border: 1px solid #313845;
+ padding: 2px 6px;
+ border-radius: 12px;
+ font-size: 0.6rem;
+ font-weight: bold;
+ letter-spacing: 1px;
+ color: #6d7889;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.pill-dot {
+ width: 5px;
+ height: 5px;
+ border-radius: 50%;
+ background: #444;
+}
+
+.on-air-badge.pill-pulse {
+ color: #ff3344;
+ border-color: #ff3344;
+ background: rgba(255, 51, 68, 0.1);
+ text-shadow: 0 0 6px rgba(255, 51, 68, 0.8);
+}
+
+.on-air-badge.pill-pulse .pill-dot {
+ background: #ff3344;
+ box-shadow: 0 0 6px #ff3344;
+ animation: blink 1s infinite alternate;
+}
+
+@keyframes blink {
+ from { opacity: 0.3; }
+ to { opacity: 1; }
+}
+
+/* ==========================================================================
+ TUNING DIAL PANEL (COMPACT)
+ ========================================================================== */
+.tuning-dial-panel {
+ background: #0e1116;
+ border: 1px solid #2d3442;
+ border-radius: 6px;
+ padding: 6px 12px;
+ box-shadow: inset 0 2px 6px #000;
+ position: relative;
+ overflow: hidden;
+}
+
+.dial-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ font-size: 0.65rem;
+ letter-spacing: 2px;
+ color: #8591a5;
+ margin-bottom: 4px;
+}
+
+.dial-indicators {
+ display: flex;
+ gap: 10px;
+}
+
+.led-badge {
+ font-size: 0.55rem;
+ letter-spacing: 1px;
+ color: #4a5464;
+ display: flex;
+ align-items: center;
+ gap: 3px;
+}
+
+.led-dot {
+ width: 5px;
+ height: 5px;
+ border-radius: 50%;
+ background: #252b36;
+ display: inline-block;
+}
+
+.led-badge.led-active.led-green {
+ color: #33ffaa;
+ text-shadow: 0 0 5px #33ffaa;
+}
+
+.led-badge.led-active.led-green .led-dot {
+ background: #33ffaa;
+ box-shadow: 0 0 6px #33ffaa;
+}
+
+.led-badge.led-active.led-amber {
+ color: #ffaa00;
+ text-shadow: 0 0 5px #ffaa00;
+}
+
+.led-badge.led-active.led-amber .led-dot {
+ background: #ffaa00;
+ box-shadow: 0 0 6px #ffaa00;
+}
+
+/* Scale & Ticks */
+.dial-scale-container {
+ height: 44px;
+ position: relative;
+ background: linear-gradient(180deg, #090c10 0%, #151a22 50%, #090c10 100%);
+ border: 1px solid #262c37;
+ border-radius: 3px;
+ cursor: pointer;
+ box-shadow: inset 0 2px 4px #000;
+}
+
+.dial-scale-background {
+ position: absolute;
+ inset: 0;
+}
+
+.dial-tick-group {
+ position: absolute;
+ top: 2px;
+ transform: translateX(-50%);
+ text-align: center;
+}
+
+.tick-major {
+ width: 1.5px;
+ height: 12px;
+ background: #ffaa00;
+ margin: 0 auto;
+}
+
+.freq-label {
+ font-size: 0.6rem;
+ font-family: var(--font-display);
+ font-weight: 700;
+ color: #ffe6a0;
+ margin-top: 1px;
+ display: block;
+}
+
+.tick-minor {
+ position: absolute;
+ top: 2px;
+ width: 1px;
+ height: 6px;
+ background: rgba(255, 170, 0, 0.4);
+ transform: translateX(-50%);
+}
+
+.station-dial-marker {
+ position: absolute;
+ bottom: 2px;
+ transform: translateX(-50%);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ cursor: pointer;
+ z-index: 5;
+}
+
+.marker-pip {
+ width: 4px;
+ height: 4px;
+ border-radius: 50%;
+ margin-bottom: 1px;
+}
+
+.marker-text {
+ font-size: 0.48rem;
+ color: #9aa7bc;
+ white-space: nowrap;
+}
+
+.station-dial-marker.marker-active .marker-text {
+ color: #fff;
+ font-weight: bold;
+}
+
+/* Red Tuning Needle */
+.tuning-needle {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ width: 2px;
+ transform: translateX(-50%);
+ z-index: 10;
+ pointer-events: none;
+ transition: left 0.1s ease-out;
+}
+
+.needle-line {
+ position: absolute;
+ inset: 0;
+ background: var(--color-needle);
+ box-shadow: 0 0 6px var(--color-needle-glow);
+}
+
+.needle-head {
+ position: absolute;
+ top: 0;
+ left: -3px;
+ width: 8px;
+ height: 6px;
+ background: #ff2a2a;
+ clip-path: polygon(0 0, 100% 0, 50% 100%);
+}
+
+/* Signal Meter Bar */
+.signal-meter-bar {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-top: 4px;
+ font-size: 0.6rem;
+ color: #8591a5;
+}
+
+.meter-track {
+ flex: 1;
+ height: 4px;
+ background: #090c10;
+ border: 1px solid #262c37;
+ border-radius: 2px;
+ overflow: hidden;
+}
+
+.meter-fill {
+ height: 100%;
+ background: linear-gradient(90deg, #ff9900 0%, #33ffaa 80%, #00f0ff 100%);
+ transition: width 0.1s ease;
+}
+
+.current-freq-readout {
+ font-family: var(--font-display);
+ font-weight: 700;
+ color: var(--color-accent-amber);
+ font-size: 0.65rem;
+}
+
+/* ==========================================================================
+ MIDDLE GRID: VINYL DECK & VFD SCREEN (COMPACT)
+ ========================================================================== */
+.faceplate-middle-grid {
+ display: grid;
+ grid-template-columns: 170px 1fr;
+ gap: 12px;
+}
+
+@media (max-width: 600px) {
+ .faceplate-middle-grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+/* Vinyl Platter */
+.artwork-deck {
+ background: #0e1116;
+ border: 1px solid #2d3442;
+ border-radius: 6px;
+ padding: 10px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ box-shadow: inset 0 2px 6px #000;
+}
+
+.vinyl-turntable {
+ width: 120px;
+ height: 120px;
+ border-radius: 50%;
+ background: #111;
+ box-shadow:
+ 0 2px 8px rgba(0, 0, 0, 0.7),
+ 0 0 0 3px #222630;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ position: relative;
+}
+
+.vinyl-disc {
+ width: 110px;
+ height: 110px;
+ border-radius: 50%;
+ background: radial-gradient(circle, #222 0%, #151515 40%, #080808 100%);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ position: relative;
+}
+
+.vinyl-grooves {
+ position: absolute;
+ inset: 3px;
+ border-radius: 50%;
+ border: 1px solid rgba(255, 255, 255, 0.05);
+ box-shadow:
+ inset 0 0 0 6px rgba(0,0,0,0.8),
+ inset 0 0 0 14px rgba(255,255,255,0.02),
+ inset 0 0 0 24px rgba(0,0,0,0.9);
+}
+
+.vinyl-center-label {
+ width: 48px;
+ height: 48px;
+ border-radius: 50%;
+ overflow: hidden;
+ border: 2px solid #ff9900;
+ box-shadow: 0 0 6px rgba(0,0,0,0.8);
+ position: relative;
+ z-index: 2;
+ background: #000;
+}
+
+.cover-image {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.fallback-cover-badge {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: #fff;
+ font-weight: 900;
+ font-family: var(--font-display);
+ font-size: 0.9rem;
+}
+
+.vinyl-turntable.vinyl-spinning .vinyl-disc {
+ animation: spin 5s linear infinite;
+}
+
+@keyframes spin {
+ from { transform: rotate(0deg); }
+ to { transform: rotate(360deg); }
+}
+
+.deck-station-caption {
+ margin-top: 6px;
+ text-align: center;
+}
+
+.caption-genre {
+ font-size: 0.6rem;
+ letter-spacing: 1px;
+ color: #8591a5;
+ text-transform: uppercase;
+}
+
+/* ==========================================================================
+ VFD PHOSPHOR SCREEN & SMART LYRICS (COMPACT)
+ ========================================================================== */
+.vfd-screen {
+ background: var(--color-vfd-bg);
+ border: 1px solid #143525;
+ border-radius: 6px;
+ padding: 8px 10px;
+ color: var(--color-accent-vfd);
+ font-family: var(--font-mono);
+ position: relative;
+ overflow: hidden;
+ height: 155px;
+ display: flex;
+ flex-direction: column;
+ box-shadow:
+ inset 0 0 15px rgba(0, 40, 25, 0.9),
+ 0 0 10px rgba(51, 255, 170, 0.12);
+}
+
+.vfd-screen.vfd-off {
+ background: #040806;
+ border-color: #0b1812;
+ color: #123022;
+}
+
+.vfd-glass-glare {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 40%;
+ background: linear-gradient(180deg, rgba(255,255,255,0.05) 0%, rgba(255,255,255,0) 100%);
+ pointer-events: none;
+}
+
+.vfd-scanlines {
+ position: absolute;
+ inset: 0;
+ background: repeating-linear-gradient(0deg, rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 0.2) 2px, transparent 2px, transparent 4px);
+ pointer-events: none;
+ z-index: 5;
+}
+
+.vfd-top-bar {
+ display: flex;
+ justify-content: space-between;
+ font-size: 0.6rem;
+ letter-spacing: 1px;
+ padding-bottom: 4px;
+ border-bottom: 1px dashed var(--color-vfd-dim);
+ margin-bottom: 4px;
+}
+
+.vfd-track-title {
+ font-family: var(--font-display);
+ font-size: 0.85rem;
+ font-weight: 700;
+ letter-spacing: 1px;
+ text-shadow: 0 0 6px var(--color-vfd-glow);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.vfd-track-artist {
+ font-size: 0.62rem;
+ color: var(--color-vfd-dim);
+ margin-top: 1px;
+}
+
+.vfd-style-tag {
+ color: var(--color-accent-amber);
+ margin-left: 4px;
+}
+
+.vfd-station-info {
+ font-size: 0.58rem;
+ color: #38bdf8;
+ margin-top: 2px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ opacity: 0.85;
+}
+
+.vfd-lyrics-panel {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ border-top: 1px solid var(--color-vfd-dim);
+ padding-top: 4px;
+ margin-top: 4px;
+}
+
+.vfd-panel-header {
+ display: flex;
+ justify-content: space-between;
+ font-size: 0.52rem;
+ letter-spacing: 1px;
+ color: var(--color-vfd-dim);
+ margin-bottom: 2px;
+}
+
+.vfd-lyrics-container {
+ flex: 1;
+ overflow-y: auto;
+ padding-right: 4px;
+ scroll-behavior: smooth;
+ scrollbar-width: thin;
+ scrollbar-color: var(--color-accent-vfd) transparent;
+}
+
+.vfd-lyrics-container::-webkit-scrollbar {
+ width: 3px;
+}
+
+.vfd-lyrics-container::-webkit-scrollbar-thumb {
+ background: var(--color-accent-vfd);
+}
+
+.vfd-lyrics-text {
+ font-family: var(--font-mono);
+ font-size: 0.68rem;
+ line-height: 1.35;
+ margin: 0;
+ white-space: pre-wrap;
+ color: #e0fff4;
+ text-shadow: 0 0 4px rgba(51, 255, 170, 0.4);
+}
+
+.vfd-no-signal {
+ text-align: center;
+ padding: 10px 4px;
+ font-size: 0.65rem;
+ color: var(--color-vfd-dim);
+}
+
+.vfd-noise-art {
+ letter-spacing: 2px;
+ margin-bottom: 4px;
+ color: var(--color-accent-amber);
+}
+
+/* ==========================================================================
+ CONTROLS, PRESETS & KNOBS (COMPACT)
+ ========================================================================== */
+.radio-controls-panel {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.presets-container {
+ background: #11141a;
+ border: 1px solid #2b323f;
+ border-radius: 4px;
+ padding: 6px 8px;
+}
+
+.panel-section-label {
+ font-size: 0.55rem;
+ letter-spacing: 1.5px;
+ color: #727f93;
+ display: block;
+ margin-bottom: 4px;
+}
+
+.preset-buttons-group {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 6px;
+}
+
+.preset-btn {
+ background: linear-gradient(180deg, #2b323e 0%, #1c212a 100%);
+ border: 1px solid #3c4556;
+ border-radius: 3px;
+ padding: 4px 6px;
+ color: #cbd5e1;
+ font-family: var(--font-mono);
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ transition: all 0.15s ease;
+}
+
+.preset-btn:hover {
+ background: linear-gradient(180deg, #384252 0%, #242b37 100%);
+}
+
+.preset-btn.preset-active {
+ border-color: var(--color-accent-amber);
+ background: linear-gradient(180deg, #44321b 0%, #291c0e 100%);
+ color: #ffaa00;
+}
+
+.preset-num {
+ font-weight: bold;
+ color: var(--color-accent-amber);
+ font-size: 0.65rem;
+}
+
+.preset-name {
+ font-size: 0.55rem;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ max-width: 55px;
+}
+
+.preset-freq {
+ font-size: 0.55rem;
+ color: #8b98ac;
+}
+
+/* Knobs Row */
+.knobs-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: nowrap;
+ background: #11141a;
+ border: 1px solid #2b323f;
+ border-radius: 6px;
+ padding: 8px 12px;
+}
+
+.control-unit {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 4px;
+}
+
+.control-label {
+ font-size: 0.55rem;
+ letter-spacing: 1px;
+ color: #838fa3;
+ font-weight: bold;
+}
+
+/* Power Toggle */
+.toggle-switch-wrapper {
+ cursor: pointer;
+ user-select: none;
+}
+
+.toggle-plate {
+ background: #1e232c;
+ border: 1px solid #363d4a;
+ border-radius: 3px;
+ padding: 2px 4px;
+ width: 32px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+
+.switch-label {
+ font-size: 0.48rem;
+ font-weight: bold;
+ color: #636f82;
+}
+
+.toggle-lever {
+ width: 10px;
+ height: 22px;
+ background: linear-gradient(180deg, #999 0%, #444 100%);
+ border-radius: 5px;
+ margin: 2px 0;
+ position: relative;
+ transition: transform 0.15s ease;
+}
+
+.lever-up {
+ transform: rotate(-18deg);
+ background: linear-gradient(180deg, #ffaa00 0%, #885500 100%);
+}
+
+.lever-down {
+ transform: rotate(18deg);
+}
+
+.lever-tip {
+ position: absolute;
+ top: 1px;
+ left: 1px;
+ right: 1px;
+ height: 6px;
+ background: #ccc;
+ border-radius: 3px;
+}
+
+/* Transport Buttons */
+.transport-buttons {
+ display: flex;
+ gap: 4px;
+}
+
+.retro-btn {
+ background: linear-gradient(180deg, #363e4d 0%, #1e232c 100%);
+ border: 1px solid #485264;
+ border-radius: 4px;
+ width: 32px;
+ height: 30px;
+ color: #d1d9e6;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+}
+
+.retro-btn-play {
+ width: 38px;
+ border-color: var(--color-accent-amber);
+ color: var(--color-accent-amber);
+}
+
+.retro-btn-play.active-playing {
+ background: linear-gradient(180deg, #4d351b 0%, #291d0e 100%);
+ box-shadow: 0 0 8px rgba(255, 153, 0, 0.4);
+}
+
+.knob-sub-controls {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.mini-btn {
+ background: #1c212a;
+ border: 1px solid #363d4a;
+ border-radius: 2px;
+ color: #9aa7bc;
+ font-size: 0.52rem;
+ padding: 1px 4px;
+ cursor: pointer;
+}
+
+.mini-btn.muted {
+ color: #ff3344;
+ border-color: #ff3344;
+}
+
+.retro-slider {
+ width: 55px;
+ accent-color: var(--color-accent-amber);
+ cursor: pointer;
+}
+
+.native-audio-toggle {
+ background: transparent;
+ border: none;
+ color: #4a5568;
+ font-size: 0.5rem;
+ font-family: var(--font-mono);
+ cursor: pointer;
+ margin-left: auto;
+ padding: 2px 6px;
+ letter-spacing: 1px;
+}
+
+.native-audio-toggle:hover {
+ color: var(--color-accent-amber);
+}
+
+.native-audio-wrapper {
+ background: #090c10;
+ border: 1px solid #2d3442;
+ border-radius: 4px;
+ padding: 4px 8px;
+ margin-top: 6px;
+}
diff --git a/src/AIRadioShowcase/wwwroot/images/ambient_cover.svg b/src/AIRadioShowcase/wwwroot/images/ambient_cover.svg
new file mode 100644
index 0000000..7c7efa8
--- /dev/null
+++ b/src/AIRadioShowcase/wwwroot/images/ambient_cover.svg
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ DEEP SPACE AMBIENT
+ 89.5 MHz RESONANCE
+
diff --git a/src/AIRadioShowcase/wwwroot/images/cyber_rock_cover.svg b/src/AIRadioShowcase/wwwroot/images/cyber_rock_cover.svg
new file mode 100644
index 0000000..9ef02ce
--- /dev/null
+++ b/src/AIRadioShowcase/wwwroot/images/cyber_rock_cover.svg
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ CYBERPUNK ROCK
+ 103.8 MHz INDUSTRIAL FM
+
diff --git a/src/AIRadioShowcase/wwwroot/images/lofi_cover.svg b/src/AIRadioShowcase/wwwroot/images/lofi_cover.svg
new file mode 100644
index 0000000..8062b80
--- /dev/null
+++ b/src/AIRadioShowcase/wwwroot/images/lofi_cover.svg
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ COSMIC LO-FI CHILL
+ 98.6 MHz // RELAX & STUDY
+
diff --git a/src/AIRadioShowcase/wwwroot/images/synthwave_cover.svg b/src/AIRadioShowcase/wwwroot/images/synthwave_cover.svg
new file mode 100644
index 0000000..423eb70
--- /dev/null
+++ b/src/AIRadioShowcase/wwwroot/images/synthwave_cover.svg
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ RETROWAVE
+ 94.2 MHz FM STEREO
+ AI BROADCAST UNIT // 1984
+
diff --git a/src/AIRadioShowcase/wwwroot/index.html b/src/AIRadioShowcase/wwwroot/index.html
index a7b9a90..0df8873 100644
--- a/src/AIRadioShowcase/wwwroot/index.html
+++ b/src/AIRadioShowcase/wwwroot/index.html
@@ -7,9 +7,12 @@
AIRadioShowcase
+
+
+
-
+
+
@@ -27,6 +30,8 @@
Reload
🗙
+
+