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 + +
+
+
+ + + + +
+
+ +
+
+
+ +
+ AI RESONANCE + STEREO RECEIVER • MODEL 2084 +
+
+ +
+
+
+
+ -20 + 0 VU + +3 +
+
+
+
+
+ L +
+ +
+
+
+ -20 + 0 VU + +3 +
+
+
+
+
+ R +
+
+ +
+
+ ON AIR +
+
+
+ +
+ +
+ +
+
+
+
+
+
+ @if (!string.IsNullOrEmpty(RadioState.CurrentTrack?.CoverUrl)) + { + Cover + } + else if (!string.IsNullOrEmpty(RadioState.TunedStation?.DefaultCoverUrl)) + { + Cover + } + else + { +
+ AI +
+ } +
+
+
+
+ @if (RadioState.TunedStation != null && RadioState.IsTuned) + { + @RadioState.TunedStation.Genre + } + else + { + NO CARRIER + } +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+ + @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 + +
+
+ +
+ @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; + + } +
+
+ +
+
+
+
+ ON +
+
+
+ OFF +
+
+ POWER +
+ +
+
+ + + + + +
+ BROADCAST +
+ +
+
+ + +
+ VOL: @((int)(RadioState.Volume * 100))% +
+ +
+
+ + + +
+ 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 + +
+
+ FM BROADCAST BAND + MHz +
+
+ TUNED +
+
+ STEREO +
+
+
+ +
+
+ @for (int f = 88; f <= 108; f += 2) + { + var pos = GetPercentageForFreq(f); +
+
+ @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; +
+ + @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.CurrentTrack != null && RadioState.IsTuned) + { +
+ @RadioState.CurrentTrack.Title +
+
+ BY: @RadioState.CurrentTrack.Artist + @if (!string.IsNullOrEmpty(RadioState.CurrentTrack.Style)) + { + [@RadioState.CurrentTrack.Style] + } +
+ @if (!string.IsNullOrEmpty(RadioState.TunedStation?.Description)) + { +
+ STATION: @RadioState.TunedStation.Description +
+ } + } + else if (RadioState.IsPoweredOn && !RadioState.IsTuned) + { +
+ FREQUENCY: @RadioState.CurrentFrequency.ToString("0.0") MHz +
+
+ NO SIGNAL DETECTED +
+ } + else + { +
+ RADIO POWER OFF +
+
+ FLIP POWER SWITCH TO RESUME +
+ } +
+ +
+
+ LYRICS & PROMPT + AUTO-SCROLL +
+ +
+ @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 + { +
+

[ SYSTEM INACTIVE ]

+
+ } +
+
+
+ +@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 🗙 + + diff --git a/src/AIRadioShowcase/wwwroot/js/retro-radio.js b/src/AIRadioShowcase/wwwroot/js/retro-radio.js new file mode 100644 index 0000000..19a4a47 --- /dev/null +++ b/src/AIRadioShowcase/wwwroot/js/retro-radio.js @@ -0,0 +1,257 @@ +// Retro Radio Rock-Solid Audio Engine +window.retroRadio = (() => { + let dotNetRef = null; + let audio = null; + let isPowered = true; + let currentVolume = 0.8; + let currentSignal = 1.0; + let isUserScrollingLyrics = false; + let userScrollTimeout = null; + + function getOrCreateAudio() { + let el = document.getElementById("radio-audio-element"); + if (!el) { + el = document.createElement("audio"); + el.id = "radio-audio-element"; + el.style.display = "none"; + document.body.appendChild(el); + } + return el; + } + + function init(dotNetReference) { + console.log("[AI Radio] Init called..."); + dotNetRef = dotNetReference; + + audio = getOrCreateAudio(); + audio.preload = "auto"; + audio.muted = false; + audio.volume = isPowered ? (currentSignal * currentVolume) : 0; + + audio.ontimeupdate = () => { + if (dotNetRef && audio.duration && !isNaN(audio.duration)) { + dotNetRef.invokeMethodAsync("OnTimeUpdate", audio.currentTime, audio.duration); + updateLyricsScroll(audio.currentTime, audio.duration); + } + }; + + audio.onended = () => { + console.log("[AI Radio] Track ended."); + if (dotNetRef) { + dotNetRef.invokeMethodAsync("OnTrackEnded"); + } + }; + + audio.onplay = () => { + console.log("[AI Radio] Native audio playing! Volume:", audio.volume); + if (dotNetRef) { + dotNetRef.invokeMethodAsync("OnAudioStateChanged", true); + } + }; + + audio.onpause = () => { + console.log("[AI Radio] Native audio paused."); + if (dotNetRef) { + dotNetRef.invokeMethodAsync("OnAudioStateChanged", false); + } + }; + + audio.onerror = (e) => { + console.error("[AI Radio] Audio error on src:", audio.src, audio.error); + // Fallback: if .wav failed, try .mp3 or vice versa + if (audio.src.endsWith(".wav")) { + const alt = audio.src.replace(/\.wav$/, ".mp3"); + console.log("[AI Radio] Trying fallback to:", alt); + audio.src = alt; + audio.play().catch(err => console.warn("Fallback play error:", err)); + } else if (audio.src.endsWith(".mp3")) { + const alt = audio.src.replace(/\.mp3$/, ".wav"); + console.log("[AI Radio] Trying fallback to:", alt); + audio.src = alt; + audio.play().catch(err => console.warn("Fallback play error:", err)); + } + }; + + // Media Session API + if ("mediaSession" in navigator) { + navigator.mediaSession.setActionHandler("play", () => dotNetRef?.invokeMethodAsync("PlayAsync")); + navigator.mediaSession.setActionHandler("pause", () => dotNetRef?.invokeMethodAsync("PauseAsync")); + navigator.mediaSession.setActionHandler("previoustrack", () => dotNetRef?.invokeMethodAsync("PreviousTrackAsync")); + navigator.mediaSession.setActionHandler("nexttrack", () => dotNetRef?.invokeMethodAsync("NextTrackAsync")); + } + + setupLyricsScrollListeners(); + startVuMeterLoop(); + console.log("[AI Radio] Audio engine ready."); + } + + function setPower(powerOn) { + console.log("[AI Radio] Set power:", powerOn); + isPowered = powerOn; + if (!audio) audio = getOrCreateAudio(); + + if (!powerOn) { + audio.pause(); + audio.volume = 0; + } else { + updateAudioLevels(); + } + } + + function updateFrequency(freq, signalStrength) { + currentSignal = Math.max(0, Math.min(1, signalStrength)); + console.log("[AI Radio] Freq: " + freq + " MHz | Signal: " + currentSignal); + updateAudioLevels(); + } + + function setVolume(vol) { + currentVolume = Math.max(0, Math.min(1, vol)); + console.log("[AI Radio] Volume:", currentVolume); + updateAudioLevels(); + } + + function updateAudioLevels() { + if (!audio) audio = getOrCreateAudio(); + const effectiveVol = isPowered ? Math.max(0, Math.min(1, currentSignal * currentVolume)) : 0; + audio.volume = effectiveVol; + audio.muted = (effectiveVol === 0); + } + + async function playTrack(url, startTime = 0) { + if (!audio) audio = getOrCreateAudio(); + if (!url) { + console.warn("[AI Radio] Empty URL provided to playTrack"); + return; + } + + const absoluteUrl = new URL(url, window.location.href).href; + console.log("[AI Radio] playTrack loading:", absoluteUrl); + + try { + if (audio.src !== absoluteUrl) { + audio.src = absoluteUrl; + } + + updateAudioLevels(); + + if (isPowered) { + const promise = audio.play(); + if (promise !== undefined) { + promise.then(() => { + console.log("[AI Radio] Playback successfully active for:", absoluteUrl); + }).catch(err => { + if (err.name === "AbortError") { + console.log("[AI Radio] Play interrupted by station switch (normal behavior)."); + } else { + console.warn("[AI Radio] Play pending error:", err); + } + }); + } + } + } catch (err) { + console.error("[AI Radio] Play exception:", err); + } + } + + function pauseTrack() { + if (!audio) audio = getOrCreateAudio(); + console.log("[AI Radio] pauseTrack called"); + audio.pause(); + } + + function seek(seconds) { + if (!audio) audio = getOrCreateAudio(); + if (!isNaN(seconds)) { + audio.currentTime = seconds; + } + } + + function fadeToStatic() { + currentSignal = 0.0; + updateAudioLevels(); + } + + function updateTrackInfo(title, artist, coverUrl, lyrics) { + if ("mediaSession" in navigator) { + navigator.mediaSession.metadata = new MediaMetadata({ + title: title || "AI Radio Broadcast", + artist: artist || "AI Synthesizer", + album: "AI Radio 24/7", + artwork: coverUrl ? [{ src: coverUrl, sizes: "512x512", type: "image/svg+xml" }] : [] + }); + } + } + + function updateLyricsScroll(currentTime, duration) { + if (isUserScrollingLyrics || !duration || duration <= 0) return; + const container = document.querySelector(".vfd-lyrics-container"); + if (!container) return; + + const maxScroll = container.scrollHeight - container.clientHeight; + if (maxScroll > 0) { + const scrollRatio = Math.min(1.0, Math.max(0.0, currentTime / duration)); + container.scrollTop = scrollRatio * maxScroll; + } + } + + function setupLyricsScrollListeners() { + document.addEventListener("wheel", (e) => { + const container = e.target.closest(".vfd-lyrics-container"); + if (container) { + isUserScrollingLyrics = true; + clearTimeout(userScrollTimeout); + userScrollTimeout = setTimeout(() => { + isUserScrollingLyrics = false; + }, 4000); + } + }, { passive: true }); + + document.addEventListener("touchstart", (e) => { + const container = e.target.closest(".vfd-lyrics-container"); + if (container) { + isUserScrollingLyrics = true; + clearTimeout(userScrollTimeout); + userScrollTimeout = setTimeout(() => { + isUserScrollingLyrics = false; + }, 4000); + } + }, { passive: true }); + } + + function startVuMeterLoop() { + const leftNeedle = document.querySelector(".vu-needle-left"); + const rightNeedle = document.querySelector(".vu-needle-right"); + + function updateMeter() { + if (isPowered && audio && !audio.paused && currentSignal > 0.1 && audio.volume > 0.05) { + const t = performance.now() * 0.005; + const beat = (Math.sin(t * 4) + Math.cos(t * 7) * 0.5 + 1.5) / 3.0; + const dynamicLevel = (0.3 + beat * 0.5) * audio.volume * currentSignal; + + const angleL = -35 + (dynamicLevel * 65); + const angleR = -35 + (dynamicLevel * 60 + (Math.sin(t * 8) * 3)); + + if (leftNeedle) leftNeedle.style.transform = "rotate(" + angleL + "deg)"; + if (rightNeedle) rightNeedle.style.transform = "rotate(" + angleR + "deg)"; + } else { + if (leftNeedle) leftNeedle.style.transform = "rotate(-35deg)"; + if (rightNeedle) rightNeedle.style.transform = "rotate(-35deg)"; + } + requestAnimationFrame(updateMeter); + } + + requestAnimationFrame(updateMeter); + } + + return { + init, + setPower, + updateFrequency, + setVolume, + playTrack, + pauseTrack, + seek, + fadeToStatic, + updateTrackInfo + }; +})();