This commit is contained in:
Michal Vrba
2026-09-11 11:11:54 +02:00
parent 3d021c651f
commit 55ea0752b6
24 changed files with 2692 additions and 6 deletions
@@ -12,4 +12,73 @@
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.12" PrivateAssets="all" /> <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.12" PrivateAssets="all" />
</ItemGroup> </ItemGroup>
<UsingTask TaskName="GenerateAudioCatalogTask" TaskFactory="RoslynCodeTaskFactory" AssemblyFile="$(MSBuildToolsPath)\Microsoft.Build.Tasks.Core.dll">
<ParameterGroup>
<AudioRoot ParameterType="System.String" Required="true" />
<OutputFile ParameterType="System.String" Required="true" />
</ParameterGroup>
<Task>
<Using Namespace="System" />
<Using Namespace="System.IO" />
<Using Namespace="System.Linq" />
<Using Namespace="System.Text" />
<Code Type="Fragment" Language="cs">
<![CDATA[
var sb = new StringBuilder();
sb.AppendLine("// <auto-generated />");
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<string, string[]> 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());
]]>
</Code>
</Task>
</UsingTask>
<Target Name="GenerateAudioCatalog" BeforeTargets="BeforeCompile;CoreCompile">
<GenerateAudioCatalogTask AudioRoot="$(MSBuildProjectDirectory)\wwwroot\audio" OutputFile="$(IntermediateOutputPath)GeneratedAudioCatalog.g.cs" />
<ItemGroup>
<Compile Include="$(IntermediateOutputPath)GeneratedAudioCatalog.g.cs" />
</ItemGroup>
</Target>
</Project> </Project>
@@ -0,0 +1,148 @@
@using AIRadioShowcase.Services
@using AIRadioShowcase.Models
@implements IDisposable
@inject RadioStateService RadioState
@inject RadioLibraryService Library
<div class="radio-cabinet @(RadioState.IsPoweredOn ? "power-on" : "power-off")">
<div class="cabinet-top-trim">
<div class="chassis-screws">
<span class="screw"></span>
<span class="screw"></span>
<span class="screw"></span>
<span class="screw"></span>
</div>
</div>
<div class="cabinet-faceplate">
<div class="faceplate-header">
<div class="brand-badge">
<span class="brand-logo">▲</span>
<div class="brand-text">
<span class="brand-name">AI RESONANCE</span>
<span class="brand-sub">STEREO RECEIVER • MODEL 2084</span>
</div>
</div>
<div class="vu-meters-cluster">
<div class="vu-meter">
<div class="vu-face">
<div class="vu-scale-art">
<span class="vu-min">-20</span>
<span class="vu-mid">0 VU</span>
<span class="vu-max">+3</span>
</div>
<div class="vu-needle-pivot">
<div class="vu-needle vu-needle-left"></div>
</div>
</div>
<span class="vu-label">L</span>
</div>
<div class="vu-meter">
<div class="vu-face">
<div class="vu-scale-art">
<span class="vu-min">-20</span>
<span class="vu-mid">0 VU</span>
<span class="vu-max">+3</span>
</div>
<div class="vu-needle-pivot">
<div class="vu-needle vu-needle-right"></div>
</div>
</div>
<span class="vu-label">R</span>
</div>
</div>
<div class="broadcast-status-pills">
<div class="pill-badge on-air-badge @(RadioState.IsPoweredOn && RadioState.IsPlaying && RadioState.IsTuned ? "pill-pulse" : "")">
<span class="pill-dot"></span> ON AIR
</div>
</div>
</div>
<div class="faceplate-section dial-section">
<TuningDial />
</div>
<div class="faceplate-middle-grid">
<div class="artwork-deck">
<div class="vinyl-turntable @(RadioState.IsPlaying && RadioState.IsTuned && RadioState.IsPoweredOn ? "vinyl-spinning" : "")">
<div class="vinyl-disc">
<div class="vinyl-grooves"></div>
<div class="vinyl-center-label" style="border-color: @(RadioState.TunedStation?.ThemeColor ?? "#ff9900");">
@if (!string.IsNullOrEmpty(RadioState.CurrentTrack?.CoverUrl))
{
<img src="@RadioState.CurrentTrack.CoverUrl" alt="Cover" class="cover-image" />
}
else if (!string.IsNullOrEmpty(RadioState.TunedStation?.DefaultCoverUrl))
{
<img src="@RadioState.TunedStation.DefaultCoverUrl" alt="Cover" class="cover-image" />
}
else
{
<div class="fallback-cover-badge" style="background-color: @(RadioState.TunedStation?.ThemeColor ?? "#222")">
<span>AI</span>
</div>
}
</div>
</div>
</div>
<div class="deck-station-caption">
@if (RadioState.TunedStation != null && RadioState.IsTuned)
{
<span class="caption-genre">@RadioState.TunedStation.Genre</span>
}
else
{
<span class="caption-genre">NO CARRIER</span>
}
</div>
</div>
<div class="screen-deck">
<VfdDisplay />
</div>
</div>
<div class="faceplate-section controls-section">
<RadioKnobs />
</div>
</div>
<div class="cabinet-bottom-trim">
<button class="native-audio-toggle" @onclick="() => showNativeControls = !showNativeControls" title="Toggle Native Browser Audio Bar">
@(showNativeControls ? "▲ HIDE BROWSER AUDIO" : "▼ NATIVE BROWSER AUDIO BAR")
</button>
</div>
@if (showNativeControls)
{
<div class="native-audio-wrapper">
<audio id="radio-audio-element" controls preload="auto" style="width: 100%; margin-top: 6px;"></audio>
</div>
}
else
{
<audio id="radio-audio-element" preload="auto" style="display:none;"></audio>
}
</div>
@code {
private bool showNativeControls = false;
protected override void OnInitialized()
{
RadioState.OnStateChanged += HandleStateChanged;
}
private void HandleStateChanged()
{
InvokeAsync(StateHasChanged);
}
public void Dispose()
{
RadioState.OnStateChanged -= HandleStateChanged;
}
}
@@ -0,0 +1,128 @@
@using AIRadioShowcase.Services
@using AIRadioShowcase.Models
@implements IDisposable
@inject RadioStateService RadioState
@inject RadioLibraryService Library
<div class="radio-controls-panel">
<div class="presets-container">
<span class="panel-section-label">PRESETS</span>
<div class="preset-buttons-group">
@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;
<button class="preset-btn @(isCurrent ? "preset-active" : "")"
title="@station.Name (@station.Frequency MHz)"
@onclick="() => RadioState.TuneToStationAsync(station)">
<span class="preset-num">@num</span>
<span class="preset-name">@station.Name</span>
<span class="preset-freq">@station.Frequency.ToString("0.0")</span>
</button>
}
</div>
</div>
<div class="knobs-row">
<div class="control-unit">
<div class="toggle-switch-wrapper" @onclick="RadioState.TogglePowerAsync">
<div class="toggle-plate">
<span class="switch-label on-label">ON</span>
<div class="toggle-lever @(RadioState.IsPoweredOn ? "lever-up" : "lever-down")">
<div class="lever-tip"></div>
</div>
<span class="switch-label off-label">OFF</span>
</div>
</div>
<span class="control-label">POWER</span>
</div>
<div class="control-unit transport-unit">
<div class="transport-buttons">
<button class="retro-btn" title="Previous Track" @onclick="RadioState.PreviousTrackAsync">
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg>
</button>
<button class="retro-btn retro-btn-play @(RadioState.IsPlaying ? "active-playing" : "")"
title="@(RadioState.IsPlaying ? "Pause" : "Play")"
@onclick="RadioState.TogglePlayPauseAsync">
@if (RadioState.IsPlaying)
{
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
}
else
{
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
}
</button>
<button class="retro-btn" title="Next Track" @onclick="RadioState.NextTrackAsync">
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg>
</button>
</div>
<span class="control-label">BROADCAST</span>
</div>
<div class="control-unit">
<div class="knob-sub-controls">
<button class="mini-btn @(RadioState.IsMuted ? "muted" : "")" @onclick="RadioState.ToggleMuteAsync" title="Mute">
@(RadioState.IsMuted ? "MUTED" : "VOL")
</button>
<input type="range" class="retro-slider" min="0" max="1" step="0.05"
value="@RadioState.Volume"
@oninput="HandleVolumeChange" />
</div>
<span class="control-label">VOL: @((int)(RadioState.Volume * 100))%</span>
</div>
<div class="control-unit">
<div class="knob-sub-controls">
<button class="mini-btn" @onclick="() => AdjustFrequency(-0.1)" title="Fine Tune Down">-0.1</button>
<input type="range" class="retro-slider" min="88.0" max="108.0" step="0.1"
value="@RadioState.CurrentFrequency"
@oninput="HandleFrequencyChange" />
<button class="mini-btn" @onclick="() => AdjustFrequency(0.1)" title="Fine Tune Up">+0.1</button>
</div>
<span class="control-label">FM TUNER</span>
</div>
</div>
</div>
@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;
}
}
@@ -0,0 +1,95 @@
@using AIRadioShowcase.Services
@using AIRadioShowcase.Models
@implements IDisposable
@inject RadioStateService RadioState
@inject RadioLibraryService Library
<div class="tuning-dial-panel @(RadioState.IsPoweredOn ? "power-on" : "power-off")">
<div class="dial-header">
<span class="scale-title">FM BROADCAST BAND</span>
<span class="scale-units">MHz</span>
<div class="dial-indicators">
<div class="led-badge @(RadioState.IsPoweredOn && RadioState.IsTuned ? "led-active led-green" : "")">
<span class="led-dot"></span> TUNED
</div>
<div class="led-badge @(RadioState.IsPoweredOn && RadioState.IsStereo ? "led-active led-amber" : "")">
<span class="led-dot"></span> STEREO
</div>
</div>
</div>
<div class="dial-scale-container" @onclick="HandleDialClick">
<div class="dial-scale-background">
@for (int f = 88; f <= 108; f += 2)
{
var pos = GetPercentageForFreq(f);
<div class="dial-tick-group" style="left: @pos%;">
<div class="tick-major"></div>
<span class="freq-label">@f</span>
</div>
}
@for (double f = 88.5; f < 108; f += 1.0)
{
var pos = GetPercentageForFreq(f);
<div class="tick-minor" style="left: @pos%;"></div>
}
@foreach (var station in Library.Stations)
{
var pos = GetPercentageForFreq(station.Frequency);
var isSelected = RadioState.TunedStation?.Id == station.Id;
<div class="station-dial-marker @(isSelected ? "marker-active" : "")"
style="left: @pos%;"
title="@station.Name (@station.Frequency MHz)"
@onclick:stopPropagation="true"
@onclick="() => RadioState.TuneToStationAsync(station)">
<span class="marker-pip" style="background-color: @station.ThemeColor"></span>
<span class="marker-text">@station.Name</span>
</div>
}
</div>
<div class="tuning-needle" style="left: @GetPercentageForFreq(RadioState.CurrentFrequency)%;">
<div class="needle-line"></div>
<div class="needle-head"></div>
</div>
</div>
<div class="signal-meter-bar">
<span class="meter-label">SIGNAL</span>
<div class="meter-track">
<div class="meter-fill" style="width: @(RadioState.IsPoweredOn ? (RadioState.SignalStrength * 100) : 0)%;"></div>
</div>
<span class="current-freq-readout">@RadioState.CurrentFrequency.ToString("0.0") MHz</span>
</div>
</div>
@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;
}
}
@@ -0,0 +1,130 @@
@using AIRadioShowcase.Services
@using AIRadioShowcase.Models
@implements IDisposable
@inject RadioStateService RadioState
<div class="vfd-screen @(RadioState.IsPoweredOn ? "vfd-on" : "vfd-off")">
<div class="vfd-glass-glare"></div>
<div class="vfd-scanlines"></div>
<div class="vfd-top-bar">
<div class="vfd-tag">
<span class="vfd-blink">●</span>
@if (RadioState.IsPoweredOn)
{
if (RadioState.IsTuned && RadioState.TunedStation != null)
{
<span>@(RadioState.IsPlaying ? "▶ PLAYING" : "⏸ PAUSED") // @RadioState.TunedStation.Name</span>
}
else
{
<span>SEEKING... [STATIC]</span>
}
}
else
{
<span>STANDBY</span>
}
</div>
<div class="vfd-time-badge">
@if (RadioState.Duration > 0)
{
<span>@FormatTime(RadioState.CurrentTime) / @FormatTime(RadioState.Duration)</span>
}
else
{
<span>--:-- / --:--</span>
}
</div>
</div>
<div class="vfd-track-meta">
@if (RadioState.IsPoweredOn && RadioState.CurrentTrack != null && RadioState.IsTuned)
{
<div class="vfd-track-title">
@RadioState.CurrentTrack.Title
</div>
<div class="vfd-track-artist">
BY: @RadioState.CurrentTrack.Artist
@if (!string.IsNullOrEmpty(RadioState.CurrentTrack.Style))
{
<span class="vfd-style-tag">[@RadioState.CurrentTrack.Style]</span>
}
</div>
@if (!string.IsNullOrEmpty(RadioState.TunedStation?.Description))
{
<div class="vfd-station-info">
STATION: @RadioState.TunedStation.Description
</div>
}
}
else if (RadioState.IsPoweredOn && !RadioState.IsTuned)
{
<div class="vfd-track-title static-text">
FREQUENCY: @RadioState.CurrentFrequency.ToString("0.0") MHz
</div>
<div class="vfd-track-artist">
NO SIGNAL DETECTED
</div>
}
else
{
<div class="vfd-track-title off-text">
RADIO POWER OFF
</div>
<div class="vfd-track-artist">
FLIP POWER SWITCH TO RESUME
</div>
}
</div>
<div class="vfd-lyrics-panel">
<div class="vfd-panel-header">
<span>LYRICS & PROMPT</span>
<span class="vfd-autoscroll-badge">AUTO-SCROLL</span>
</div>
<div class="vfd-lyrics-container">
@if (RadioState.IsPoweredOn && RadioState.IsTuned && !string.IsNullOrWhiteSpace(RadioState.CurrentLyrics))
{
<pre class="vfd-lyrics-text">@RadioState.CurrentLyrics</pre>
}
else if (RadioState.IsPoweredOn && !RadioState.IsTuned)
{
<div class="vfd-no-signal">
<div class="vfd-noise-art">░░▒▒▓▓ NO CARRIER ▓▓▒▒░░</div>
<p>Tune the frequency to lock onto a station.</p>
</div>
}
else
{
<div class="vfd-no-signal">
<p>[ SYSTEM INACTIVE ]</p>
</div>
}
</div>
</div>
</div>
@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;
}
}
@@ -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<RadioTrack> Tracks { get; set; } = new();
}
+16
View File
@@ -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;
}
+15 -3
View File
@@ -1,7 +1,19 @@
@page "/" @page "/"
@inject RadioStateService RadioState
@inject RadioLibraryService Library
<PageTitle>Home</PageTitle> <PageTitle>AI Retro Radio 24/7</PageTitle>
<h1>Hello, world!</h1> <div class="radio-app-wrapper">
<RadioCabinet />
</div>
Welcome to your new app. @code {
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await RadioState.InitializeAsync();
}
}
}
+3
View File
@@ -1,4 +1,5 @@
using AIRadioShowcase; using AIRadioShowcase;
using AIRadioShowcase.Services;
using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting; using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
@@ -7,5 +8,7 @@ builder.RootComponents.Add<App>("#app");
builder.RootComponents.Add<HeadOutlet>("head::after"); builder.RootComponents.Add<HeadOutlet>("head::after");
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) }); builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
builder.Services.AddScoped<RadioLibraryService>();
builder.Services.AddScoped<RadioStateService>();
await builder.Build().RunAsync(); await builder.Build().RunAsync();
@@ -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();
}
}
@@ -0,0 +1,234 @@
using System.Globalization;
using AIRadioShowcase.Models;
namespace AIRadioShowcase.Services;
public class RadioLibraryService
{
private readonly HttpClient _http;
private List<RadioStation> _stations = new();
public RadioLibraryService(HttpClient http)
{
_http = http;
}
public IReadOnlyList<RadioStation> Stations => _stations;
public async Task<List<RadioStation>> LoadStationsAsync()
{
var folders = AudioCatalog.Folders;
var loaded = new List<RadioStation>();
int index = 0;
foreach (var folder in folders)
{
var station = await LoadStationFromFolderAsync(folder, index++);
loaded.Add(station);
}
_stations = loaded;
return _stations;
}
private async Task<RadioStation> 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<string>();
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<string> 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.";
}
}
}
@@ -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<RadioStateService>? _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();
}
}
+4 -1
View File
@@ -1,4 +1,4 @@
@using System.Net.Http @using System.Net.Http
@using System.Net.Http.Json @using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing @using Microsoft.AspNetCore.Components.Routing
@@ -8,3 +8,6 @@
@using Microsoft.JSInterop @using Microsoft.JSInterop
@using AIRadioShowcase @using AIRadioShowcase
@using AIRadioShowcase.Layout @using AIRadioShowcase.Layout
@using AIRadioShowcase.Models
@using AIRadioShowcase.Services
@using AIRadioShowcase.Components
@@ -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
@@ -0,0 +1,3 @@
Neon nights, analog synthesizers
Genre: Synthwave / Outrun
Freq: 94.2
+950
View File
@@ -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;
}
@@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" width="100%" height="100%">
<defs>
<radialGradient id="ambbg" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="#03251e"/>
<stop offset="60%" stop-color="#021411"/>
<stop offset="100%" stop-color="#010807"/>
</radialGradient>
</defs>
<rect width="400" height="400" fill="url(#ambbg)" rx="16"/>
<!-- Orbiting rings -->
<ellipse cx="200" cy="200" rx="140" ry="40" fill="none" stroke="#74ee15" stroke-width="2" opacity="0.6" transform="rotate(-25 200 200)"/>
<ellipse cx="200" cy="200" rx="100" ry="25" fill="none" stroke="#00f0ff" stroke-width="1.5" opacity="0.8" transform="rotate(-25 200 200)"/>
<circle cx="200" cy="200" r="35" fill="#021411" stroke="#74ee15" stroke-width="3"/>
<circle cx="200" cy="200" r="15" fill="#74ee15" opacity="0.8"/>
<text x="200" y="60" font-family="'Orbitron', sans-serif" font-size="20" font-weight="900" fill="#74ee15" text-anchor="middle" letter-spacing="4">DEEP SPACE AMBIENT</text>
<text x="200" y="360" font-family="'Share Tech Mono', monospace" font-size="14" fill="#00f0ff" text-anchor="middle" letter-spacing="3">89.5 MHz RESONANCE</text>
</svg>
@@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" width="100%" height="100%">
<defs>
<linearGradient id="rockbg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#1a1100"/>
<stop offset="50%" stop-color="#2d1c02"/>
<stop offset="100%" stop-color="#0a0500"/>
</linearGradient>
</defs>
<rect width="400" height="400" fill="url(#rockbg)" rx="16"/>
<!-- Glitch / Hazard stripes -->
<path d="M20 20 L380 20 L380 30 L20 30 Z" fill="#ffe700"/>
<path d="M20 370 L380 370 L380 380 L20 380 Z" fill="#ffe700"/>
<!-- Electric Lightning / Guitar Symbol -->
<polygon points="200,60 160,190 220,190 180,340 250,180 190,180" fill="#ffe700" stroke="#ff5500" stroke-width="4"/>
<text x="200" y="50" font-family="'Orbitron', sans-serif" font-size="20" font-weight="900" fill="#ffe700" text-anchor="middle" letter-spacing="4">CYBERPUNK ROCK</text>
<text x="200" y="360" font-family="'Share Tech Mono', monospace" font-size="14" fill="#ff5500" text-anchor="middle" letter-spacing="3">103.8 MHz INDUSTRIAL FM</text>
</svg>
@@ -0,0 +1,36 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" width="100%" height="100%">
<defs>
<linearGradient id="lofibg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#151b29"/>
<stop offset="100%" stop-color="#0b0e14"/>
</linearGradient>
<linearGradient id="moon" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#fdfbf7"/>
<stop offset="100%" stop-color="#ebc288"/>
</linearGradient>
</defs>
<rect width="400" height="400" fill="url(#lofibg)" rx="16"/>
<!-- Window frame -->
<rect x="50" y="60" width="300" height="260" rx="8" fill="#1b2438" stroke="#33425b" stroke-width="4"/>
<!-- Moon -->
<circle cx="270" cy="130" r="45" fill="url(#moon)" opacity="0.9"/>
<!-- Stars -->
<circle cx="90" cy="100" r="2" fill="#fff" opacity="0.8"/>
<circle cx="150" cy="85" r="1.5" fill="#fff" opacity="0.7"/>
<circle cx="210" cy="110" r="2" fill="#fff" opacity="0.9"/>
<circle cx="120" cy="150" r="1.5" fill="#fff" opacity="0.6"/>
<!-- Window Cross -->
<line x1="200" y1="60" x2="200" y2="320" stroke="#33425b" stroke-width="4"/>
<line x1="50" y1="190" x2="350" y2="190" stroke="#33425b" stroke-width="4"/>
<!-- Coffee mug -->
<rect x="70" y="270" width="30" height="40" rx="4" fill="#d97736"/>
<path d="M100 280 C110 280, 110 300, 100 300" stroke="#d97736" stroke-width="3" fill="none"/>
<!-- Steam -->
<path d="M78 260 Q83 250 80 240 Q77 230 82 220" stroke="rgba(255,255,255,0.4)" stroke-width="2" fill="none"/>
<!-- Plant on sill -->
<path d="M300 285 Q285 270 280 250 Q295 260 300 285" fill="#4ade80"/>
<path d="M305 285 Q320 268 330 255 Q320 275 305 285" fill="#22c55e"/>
<text x="200" y="45" font-family="'Share Tech Mono', monospace" font-size="18" font-weight="bold" fill="#38bdf8" text-anchor="middle" letter-spacing="3">COSMIC LO-FI CHILL</text>
<text x="200" y="365" font-family="'Share Tech Mono', monospace" font-size="14" fill="#94a3b8" text-anchor="middle" letter-spacing="2">98.6 MHz // RELAX &amp; STUDY</text>
</svg>
@@ -0,0 +1,47 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" width="100%" height="100%">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#1a002c"/>
<stop offset="50%" stop-color="#2c003e"/>
<stop offset="100%" stop-color="#0d001a"/>
</linearGradient>
<linearGradient id="sun" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#ff007f"/>
<stop offset="50%" stop-color="#ff9900"/>
<stop offset="100%" stop-color="#ffe600"/>
</linearGradient>
<linearGradient id="gridGrad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="rgba(0, 240, 255, 0.8)"/>
<stop offset="100%" stop-color="rgba(255, 0, 127, 0.1)"/>
</linearGradient>
</defs>
<rect width="400" height="400" fill="url(#bg)" rx="16"/>
<!-- Sun -->
<circle cx="200" cy="180" r="85" fill="url(#sun)" />
<line x1="110" y1="160" x2="290" y2="160" stroke="#2c003e" stroke-width="3"/>
<line x1="110" y1="175" x2="290" y2="175" stroke="#2c003e" stroke-width="4"/>
<line x1="115" y1="192" x2="285" y2="192" stroke="#2c003e" stroke-width="5"/>
<line x1="125" y1="210" x2="275" y2="210" stroke="#2c003e" stroke-width="6"/>
<line x1="140" y1="230" x2="260" y2="230" stroke="#2c003e" stroke-width="7"/>
<!-- Perspective Grid Floor -->
<path d="M0 260 L400 260 L400 400 L0 400 Z" fill="#0d001a" />
<g stroke="url(#gridGrad)" stroke-width="1.5">
<line x1="0" y1="260" x2="400" y2="260"/>
<line x1="0" y1="280" x2="400" y2="280"/>
<line x1="0" y1="310" x2="400" y2="310"/>
<line x1="0" y1="350" x2="400" y2="350"/>
<line x1="200" y1="260" x2="200" y2="400"/>
<line x1="200" y1="260" x2="100" y2="400"/>
<line x1="200" y1="260" x2="0" y2="400"/>
<line x1="200" y1="260" x2="300" y2="400"/>
<line x1="200" y1="260" x2="400" y2="400"/>
</g>
<!-- Typography -->
<text x="200" y="65" font-family="'Orbitron', sans-serif" font-size="24" font-weight="900" fill="#00f0ff" text-anchor="middle" letter-spacing="4">RETROWAVE</text>
<text x="200" y="90" font-family="'Share Tech Mono', monospace" font-size="14" fill="#ff007f" text-anchor="middle" letter-spacing="3">94.2 MHz FM STEREO</text>
<text x="200" y="385" font-family="'Share Tech Mono', monospace" font-size="12" fill="#ffe600" text-anchor="middle" letter-spacing="2">AI BROADCAST UNIT // 1984</text>
</svg>
+7 -2
View File
@@ -7,9 +7,12 @@
<title>AIRadioShowcase</title> <title>AIRadioShowcase</title>
<base href="/" /> <base href="/" />
<link rel="preload" id="webassembly" /> <link rel="preload" id="webassembly" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@500;700;900&family=Rajdhani:wght@500;700&family=Share+Tech+Mono&display=swap" rel="stylesheet">
<link rel="stylesheet" href="css/app.css" /> <link rel="stylesheet" href="css/app.css" />
<!-- If you add any scoped CSS files, uncomment the following to load them <link rel="stylesheet" href="css/radio.css" />
<link href="AIRadioShowcase.styles.css" rel="stylesheet" /> --> <link rel="icon" type="image/png" href="icon-192.png" />
<script type="importmap"></script> <script type="importmap"></script>
</head> </head>
@@ -27,6 +30,8 @@
<a href="." class="reload">Reload</a> <a href="." class="reload">Reload</a>
<span class="dismiss">🗙</span> <span class="dismiss">🗙</span>
</div> </div>
<script src="js/retro-radio.js?v=5"></script>
<script src="_framework/blazor.webassembly#[.{fingerprint}].js"></script> <script src="_framework/blazor.webassembly#[.{fingerprint}].js"></script>
</body> </body>
@@ -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
};
})();