@if (RadioState.IsPoweredOn && RadioState.CurrentTrack != null && RadioState.IsTuned)
{
@@ -81,7 +98,12 @@
@@ -107,6 +129,15 @@
@code {
+ private bool _isDragging = false;
+ private double _dragTime = 0;
+
+ private double DisplayTime => _isDragging ? _dragTime : RadioState.CurrentTime;
+
+ private double ProgressPercentage => RadioState.Duration > 0
+ ? Math.Clamp((DisplayTime / RadioState.Duration) * 100.0, 0.0, 100.0)
+ : 0.0;
+
protected override void OnInitialized()
{
RadioState.OnStateChanged += HandleStateChanged;
@@ -114,12 +145,36 @@
private void HandleStateChanged()
{
- InvokeAsync(StateHasChanged);
+ if (!_isDragging)
+ {
+ InvokeAsync(StateHasChanged);
+ }
+ }
+
+ private void HandleProgressInput(ChangeEventArgs e)
+ {
+ if (double.TryParse(e.Value?.ToString(), System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out double val))
+ {
+ _isDragging = true;
+ _dragTime = val;
+ StateHasChanged();
+ }
+ }
+
+ private async Task HandleProgressChange(ChangeEventArgs e)
+ {
+ if (double.TryParse(e.Value?.ToString(), System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out double val))
+ {
+ _dragTime = val;
+ await RadioState.SeekAsync(_dragTime);
+ }
+ _isDragging = false;
+ StateHasChanged();
}
private string FormatTime(double seconds)
{
- var ts = TimeSpan.FromSeconds(seconds);
+ var ts = TimeSpan.FromSeconds(Math.Max(0, seconds));
return $"{(int)ts.TotalMinutes:D2}:{ts.Seconds:D2}";
}
diff --git a/src/AIRadioShowcase/Services/RadioLibraryService.cs b/src/AIRadioShowcase/Services/RadioLibraryService.cs
index 159ff0f..9dda903 100644
--- a/src/AIRadioShowcase/Services/RadioLibraryService.cs
+++ b/src/AIRadioShowcase/Services/RadioLibraryService.cs
@@ -122,7 +122,7 @@ public class RadioLibraryService
foreach (System.Text.RegularExpressions.Match m in matches)
{
var href = m.Groups[1].Value.Trim();
- href = href.TrimEnd('/');
+ href = Uri.UnescapeDataString(href).TrimEnd('/');
var name = Path.GetFileName(href);
if (string.IsNullOrWhiteSpace(name) || name.StartsWith(".") || name.Equals("audio", StringComparison.OrdinalIgnoreCase))
continue;
@@ -144,6 +144,7 @@ public class RadioLibraryService
foreach (System.Text.RegularExpressions.Match m in matches)
{
var href = m.Groups[1].Value.Trim();
+ href = Uri.UnescapeDataString(href);
var name = Path.GetFileName(href);
var ext = Path.GetExtension(name);
if (validExts.Contains(ext))
@@ -243,14 +244,15 @@ public class RadioLibraryService
private RadioTrack BuildTrack(string folder, string fileName, string? defaultCover)
{
- var nameWithoutExt = Path.GetFileNameWithoutExtension(fileName);
+ var unescapedFileName = Uri.UnescapeDataString(fileName);
+ var nameWithoutExt = Path.GetFileNameWithoutExtension(unescapedFileName);
ParseArtistAndTitle(nameWithoutExt, out string artist, out string title);
var track = new RadioTrack
{
Title = title,
Artist = artist,
- FileName = fileName,
+ FileName = unescapedFileName,
AudioUrl = $"audio/{folder}/{fileName}",
LyricsUrl = $"audio/{folder}/{nameWithoutExt}.txt",
CoverUrl = defaultCover
@@ -261,10 +263,11 @@ public class RadioLibraryService
private void ParseArtistAndTitle(string nameWithoutExt, out string artist, out string title)
{
+ nameWithoutExt = Uri.UnescapeDataString(nameWithoutExt);
if (nameWithoutExt.Contains(" - "))
{
var parts = nameWithoutExt.Split(new[] { " - " }, 2, StringSplitOptions.TrimEntries);
- artist = parts[0];
+ artist = FormatName(parts[0]);
title = FormatName(parts[1]);
}
else
@@ -276,14 +279,17 @@ public class RadioLibraryService
private string FormatName(string raw)
{
- var cleaned = raw.Replace('_', ' ').Replace('-', ' ').Trim();
+ var unescaped = Uri.UnescapeDataString(raw);
+ var cleaned = unescaped.Replace('_', ' ').Trim();
+ cleaned = System.Text.RegularExpressions.Regex.Replace(cleaned, @"\s+", " ");
return CultureInfo.CurrentCulture.TextInfo.ToTitleCase(cleaned);
}
private string FormatStationName(string folderName)
{
- // Replace underscore with space
- var clean = folderName.Replace('_', ' ').Trim();
+ var unescaped = Uri.UnescapeDataString(folderName);
+ var clean = unescaped.Replace('_', ' ').Trim();
+ clean = System.Text.RegularExpressions.Regex.Replace(clean, @"\s+", " ");
return CultureInfo.CurrentCulture.TextInfo.ToTitleCase(clean);
}
diff --git a/src/AIRadioShowcase/Services/RadioStateService.cs b/src/AIRadioShowcase/Services/RadioStateService.cs
index 68e76c8..e94f33f 100644
--- a/src/AIRadioShowcase/Services/RadioStateService.cs
+++ b/src/AIRadioShowcase/Services/RadioStateService.cs
@@ -31,6 +31,7 @@ public class RadioStateService : IDisposable
public double CurrentTime { get; private set; } = 0;
public double Duration { get; private set; } = 0;
public string CurrentLyrics { get; private set; } = string.Empty;
+ public bool IsAutoScrollEnabled { get; private set; } = true;
public RadioStateService(IJSRuntime js, RadioLibraryService library)
{
@@ -223,11 +224,14 @@ public class RadioStateService : IDisposable
CurrentTrackIndex = (CurrentTrackIndex + 1) % TunedStation.Tracks.Count;
CurrentTime = 0;
- await LoadTrackDataAsync();
- if (IsPoweredOn && IsPlaying)
+ if (IsPoweredOn && IsTuned)
{
await PlayCurrentTrackAsync();
}
+ else
+ {
+ await LoadTrackDataAsync();
+ }
NotifyStateChanged();
}
@@ -238,11 +242,14 @@ public class RadioStateService : IDisposable
CurrentTrackIndex = (CurrentTrackIndex - 1 + TunedStation.Tracks.Count) % TunedStation.Tracks.Count;
CurrentTime = 0;
- await LoadTrackDataAsync();
- if (IsPoweredOn && IsPlaying)
+ if (IsPoweredOn && IsTuned)
{
await PlayCurrentTrackAsync();
}
+ else
+ {
+ await LoadTrackDataAsync();
+ }
NotifyStateChanged();
}
@@ -277,6 +284,20 @@ public class RadioStateService : IDisposable
NotifyStateChanged();
}
+ public async Task ToggleAutoScrollAsync()
+ {
+ IsAutoScrollEnabled = !IsAutoScrollEnabled;
+ try
+ {
+ await _js.InvokeVoidAsync("retroRadio.setAutoScroll", IsAutoScrollEnabled);
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"AutoScroll toggle error: {ex.Message}");
+ }
+ NotifyStateChanged();
+ }
+
[JSInvokable]
public async Task SeekAsync(double seconds)
{
@@ -358,7 +379,19 @@ public class RadioStateService : IDisposable
[JSInvokable]
public async Task OnTrackEnded()
{
- await NextTrackAsync();
+ if (!IsPoweredOn || TunedStation == null || TunedStation.Tracks.Count == 0) return;
+
+ if (TunedStation.Tracks.Count == 1)
+ {
+ // Single track station: seamless loop
+ CurrentTime = 0;
+ await PlayCurrentTrackAsync();
+ }
+ else
+ {
+ // Multiple tracks: auto advance to next track
+ await NextTrackAsync();
+ }
}
[JSInvokable]
diff --git a/src/AIRadioShowcase/wwwroot/css/radio.css b/src/AIRadioShowcase/wwwroot/css/radio.css
index 9945390..b6dc656 100644
--- a/src/AIRadioShowcase/wwwroot/css/radio.css
+++ b/src/AIRadioShowcase/wwwroot/css/radio.css
@@ -583,7 +583,7 @@ html, body {
font-family: var(--font-mono);
position: relative;
overflow: hidden;
- height: 155px;
+ height: 235px;
display: flex;
flex-direction: column;
box-shadow:
@@ -625,6 +625,74 @@ html, body {
margin-bottom: 4px;
}
+/* Song Progress Indicator & Scrubber */
+.vfd-progress-wrapper {
+ position: relative;
+ height: 12px;
+ margin: 2px 0 6px 0;
+ display: flex;
+ align-items: center;
+ cursor: pointer;
+}
+
+.vfd-progress-track {
+ position: absolute;
+ left: 0;
+ right: 0;
+ height: 6px;
+ background: #040e09;
+ border: 1px solid #143525;
+ border-radius: 3px;
+ overflow: hidden;
+ pointer-events: none;
+ box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.8);
+}
+
+.vfd-progress-fill {
+ height: 100%;
+ background: linear-gradient(90deg, #00f0ff 0%, #33ffaa 70%, #ffaa00 100%);
+ box-shadow: 0 0 8px rgba(51, 255, 170, 0.7);
+ border-radius: 2px;
+ transition: width 0.08s linear;
+}
+
+.vfd-progress-thumb {
+ position: absolute;
+ top: 50%;
+ width: 6px;
+ height: 11px;
+ background: #ffffff;
+ border: 1px solid #ffaa00;
+ border-radius: 2px;
+ transform: translate(-50%, -50%);
+ box-shadow: 0 0 6px #ffaa00, 0 0 10px #00f0ff;
+ pointer-events: none;
+ z-index: 2;
+ transition: left 0.08s linear;
+}
+
+.vfd-progress-slider {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ opacity: 0;
+ cursor: pointer;
+ z-index: 10;
+ margin: 0;
+}
+
+.vfd-progress-wrapper:hover .vfd-progress-track {
+ border-color: #33ffaa;
+ box-shadow: 0 0 6px rgba(51, 255, 170, 0.3), inset 0 1px 3px rgba(0, 0, 0, 0.8);
+}
+
+.vfd-progress-wrapper.progress-disabled {
+ opacity: 0.35;
+ cursor: not-allowed;
+ pointer-events: none;
+}
+
.vfd-track-title {
font-family: var(--font-display);
font-size: 0.85rem;
@@ -670,12 +738,55 @@ html, body {
.vfd-panel-header {
display: flex;
justify-content: space-between;
+ align-items: center;
font-size: 0.52rem;
letter-spacing: 1px;
color: var(--color-vfd-dim);
margin-bottom: 2px;
}
+.vfd-autoscroll-toggle {
+ background: transparent;
+ border: 1px solid var(--color-vfd-dim);
+ border-radius: 3px;
+ font-family: var(--font-mono);
+ font-size: 0.52rem;
+ padding: 1px 5px;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ gap: 3px;
+ transition: all 0.15s ease;
+ user-select: none;
+}
+
+.vfd-autoscroll-toggle.autoscroll-on {
+ color: var(--color-accent-vfd);
+ border-color: var(--color-accent-vfd);
+ background: rgba(51, 255, 170, 0.12);
+ text-shadow: 0 0 5px var(--color-vfd-glow);
+}
+
+.vfd-autoscroll-toggle.autoscroll-on .autoscroll-led {
+ color: #33ffaa;
+ text-shadow: 0 0 6px #33ffaa;
+}
+
+.vfd-autoscroll-toggle.autoscroll-off {
+ color: #6d7889;
+ border-color: #313845;
+ background: rgba(0, 0, 0, 0.3);
+}
+
+.vfd-autoscroll-toggle.autoscroll-off .autoscroll-led {
+ color: #444;
+}
+
+.vfd-autoscroll-toggle:hover {
+ filter: brightness(1.25);
+ border-color: var(--color-accent-amber);
+}
+
.vfd-lyrics-container {
flex: 1;
overflow-y: auto;
diff --git a/src/AIRadioShowcase/wwwroot/index.html b/src/AIRadioShowcase/wwwroot/index.html
index 6cd570c..fb5e040 100644
--- a/src/AIRadioShowcase/wwwroot/index.html
+++ b/src/AIRadioShowcase/wwwroot/index.html
@@ -33,7 +33,7 @@
🗙