impl
This commit is contained in:
@@ -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();
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
Binary file not shown.
@@ -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
|
||||||
@@ -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 & 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,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
|
||||||
|
};
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user