diff --git a/src/AIRadioShowcase/Components/VfdDisplay.razor b/src/AIRadioShowcase/Components/VfdDisplay.razor index a318009..25a3351 100644 --- a/src/AIRadioShowcase/Components/VfdDisplay.razor +++ b/src/AIRadioShowcase/Components/VfdDisplay.razor @@ -29,7 +29,7 @@
@if (RadioState.Duration > 0) { - @FormatTime(RadioState.CurrentTime) / @FormatTime(RadioState.Duration) + @FormatTime(DisplayTime) / @FormatTime(RadioState.Duration) } else { @@ -38,6 +38,23 @@
+ +
+
+
+
+
+ +
+
@if (RadioState.IsPoweredOn && RadioState.CurrentTrack != null && RadioState.IsTuned) { @@ -81,7 +98,12 @@
LYRICS & PROMPT - AUTO-SCROLL +
@@ -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 @@ 🗙
- + diff --git a/src/AIRadioShowcase/wwwroot/js/retro-radio.js b/src/AIRadioShowcase/wwwroot/js/retro-radio.js index 19a4a47..c6789d3 100644 --- a/src/AIRadioShowcase/wwwroot/js/retro-radio.js +++ b/src/AIRadioShowcase/wwwroot/js/retro-radio.js @@ -5,6 +5,7 @@ window.retroRadio = (() => { let isPowered = true; let currentVolume = 0.8; let currentSignal = 1.0; + let isAutoScrollEnabled = true; let isUserScrollingLyrics = false; let userScrollTimeout = null; @@ -131,6 +132,7 @@ window.retroRadio = (() => { if (audio.src !== absoluteUrl) { audio.src = absoluteUrl; } + audio.currentTime = startTime; updateAudioLevels(); @@ -183,15 +185,27 @@ window.retroRadio = (() => { } function updateLyricsScroll(currentTime, duration) { - if (isUserScrollingLyrics || !duration || duration <= 0) return; + if (!isAutoScrollEnabled || 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; + if (maxScroll <= 0) return; + + const INTRO_DELAY = 8.0; // Wait approx 8 seconds before text scrolling starts + if (currentTime <= INTRO_DELAY) { + container.scrollTop = 0; + return; } + + const effectiveDuration = Math.max(1.0, duration - INTRO_DELAY); + const scrollRatio = Math.min(1.0, Math.max(0.0, (currentTime - INTRO_DELAY) / effectiveDuration)); + container.scrollTop = scrollRatio * maxScroll; + } + + function setAutoScroll(enabled) { + isAutoScrollEnabled = !!enabled; + console.log("[AI Radio] Auto-scroll enabled:", isAutoScrollEnabled); } function setupLyricsScrollListeners() { @@ -252,6 +266,7 @@ window.retroRadio = (() => { pauseTrack, seek, fadeToStatic, - updateTrackInfo + updateTrackInfo, + setAutoScroll }; })();