impl
This commit is contained in:
@@ -12,4 +12,73 @@
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.12" PrivateAssets="all" />
|
||||
</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>
|
||||
|
||||
@@ -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 "/"
|
||||
@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.Services;
|
||||
using Microsoft.AspNetCore.Components.Web;
|
||||
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
|
||||
|
||||
@@ -7,5 +8,7 @@ builder.RootComponents.Add<App>("#app");
|
||||
builder.RootComponents.Add<HeadOutlet>("head::after");
|
||||
|
||||
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
|
||||
builder.Services.AddScoped<RadioLibraryService>();
|
||||
builder.Services.AddScoped<RadioStateService>();
|
||||
|
||||
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 Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@@ -8,3 +8,6 @@
|
||||
@using Microsoft.JSInterop
|
||||
@using AIRadioShowcase
|
||||
@using AIRadioShowcase.Layout
|
||||
@using AIRadioShowcase.Models
|
||||
@using AIRadioShowcase.Services
|
||||
@using AIRadioShowcase.Components
|
||||
|
||||
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>
|
||||
<base href="/" />
|
||||
<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" />
|
||||
<!-- If you add any scoped CSS files, uncomment the following to load them
|
||||
<link href="AIRadioShowcase.styles.css" rel="stylesheet" /> -->
|
||||
<link rel="stylesheet" href="css/radio.css" />
|
||||
<link rel="icon" type="image/png" href="icon-192.png" />
|
||||
<script type="importmap"></script>
|
||||
</head>
|
||||
|
||||
@@ -27,6 +30,8 @@
|
||||
<a href="." class="reload">Reload</a>
|
||||
<span class="dismiss">🗙</span>
|
||||
</div>
|
||||
|
||||
<script src="js/retro-radio.js?v=5"></script>
|
||||
<script src="_framework/blazor.webassembly#[.{fingerprint}].js"></script>
|
||||
</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