This commit is contained in:
Michal Vrba
2026-09-11 12:57:08 +02:00
parent d2fc5b8f43
commit 8f18927f89
6 changed files with 243 additions and 23 deletions
@@ -29,7 +29,7 @@
<div class="vfd-time-badge"> <div class="vfd-time-badge">
@if (RadioState.Duration > 0) @if (RadioState.Duration > 0)
{ {
<span>@FormatTime(RadioState.CurrentTime) / @FormatTime(RadioState.Duration)</span> <span>@FormatTime(DisplayTime) / @FormatTime(RadioState.Duration)</span>
} }
else else
{ {
@@ -38,6 +38,23 @@
</div> </div>
</div> </div>
<!-- Interactive Track Progress Indicator / Scrubber -->
<div class="vfd-progress-wrapper @(RadioState.IsPoweredOn && RadioState.IsTuned && RadioState.Duration > 0 ? "" : "progress-disabled")">
<div class="vfd-progress-track">
<div class="vfd-progress-fill" style="width: @ProgressPercentage.ToString("0.0", System.Globalization.CultureInfo.InvariantCulture)%"></div>
<div class="vfd-progress-thumb" style="left: @ProgressPercentage.ToString("0.0", System.Globalization.CultureInfo.InvariantCulture)%"></div>
</div>
<input type="range" class="vfd-progress-slider"
min="0"
max="@(RadioState.Duration > 0 ? RadioState.Duration : 100)"
step="0.2"
value="@DisplayTime"
@oninput="HandleProgressInput"
@onchange="HandleProgressChange"
disabled="@(!RadioState.IsPoweredOn || !RadioState.IsTuned || RadioState.Duration <= 0)"
title="Drag or click to seek" />
</div>
<div class="vfd-track-meta"> <div class="vfd-track-meta">
@if (RadioState.IsPoweredOn && RadioState.CurrentTrack != null && RadioState.IsTuned) @if (RadioState.IsPoweredOn && RadioState.CurrentTrack != null && RadioState.IsTuned)
{ {
@@ -81,7 +98,12 @@
<div class="vfd-lyrics-panel"> <div class="vfd-lyrics-panel">
<div class="vfd-panel-header"> <div class="vfd-panel-header">
<span>LYRICS & PROMPT</span> <span>LYRICS & PROMPT</span>
<span class="vfd-autoscroll-badge">AUTO-SCROLL</span> <button type="button" class="vfd-autoscroll-toggle @(RadioState.IsAutoScrollEnabled ? "autoscroll-on" : "autoscroll-off")"
@onclick="RadioState.ToggleAutoScrollAsync"
title="Click to toggle auto-scrolling lyrics ON / OFF">
<span class="autoscroll-led">●</span>
AUTO-SCROLL @(RadioState.IsAutoScrollEnabled ? "ON" : "OFF")
</button>
</div> </div>
<div class="vfd-lyrics-container"> <div class="vfd-lyrics-container">
@@ -107,6 +129,15 @@
</div> </div>
@code { @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() protected override void OnInitialized()
{ {
RadioState.OnStateChanged += HandleStateChanged; RadioState.OnStateChanged += HandleStateChanged;
@@ -114,12 +145,36 @@
private void HandleStateChanged() 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) 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}"; return $"{(int)ts.TotalMinutes:D2}:{ts.Seconds:D2}";
} }
@@ -122,7 +122,7 @@ public class RadioLibraryService
foreach (System.Text.RegularExpressions.Match m in matches) foreach (System.Text.RegularExpressions.Match m in matches)
{ {
var href = m.Groups[1].Value.Trim(); var href = m.Groups[1].Value.Trim();
href = href.TrimEnd('/'); href = Uri.UnescapeDataString(href).TrimEnd('/');
var name = Path.GetFileName(href); var name = Path.GetFileName(href);
if (string.IsNullOrWhiteSpace(name) || name.StartsWith(".") || name.Equals("audio", StringComparison.OrdinalIgnoreCase)) if (string.IsNullOrWhiteSpace(name) || name.StartsWith(".") || name.Equals("audio", StringComparison.OrdinalIgnoreCase))
continue; continue;
@@ -144,6 +144,7 @@ public class RadioLibraryService
foreach (System.Text.RegularExpressions.Match m in matches) foreach (System.Text.RegularExpressions.Match m in matches)
{ {
var href = m.Groups[1].Value.Trim(); var href = m.Groups[1].Value.Trim();
href = Uri.UnescapeDataString(href);
var name = Path.GetFileName(href); var name = Path.GetFileName(href);
var ext = Path.GetExtension(name); var ext = Path.GetExtension(name);
if (validExts.Contains(ext)) if (validExts.Contains(ext))
@@ -243,14 +244,15 @@ public class RadioLibraryService
private RadioTrack BuildTrack(string folder, string fileName, string? defaultCover) 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); ParseArtistAndTitle(nameWithoutExt, out string artist, out string title);
var track = new RadioTrack var track = new RadioTrack
{ {
Title = title, Title = title,
Artist = artist, Artist = artist,
FileName = fileName, FileName = unescapedFileName,
AudioUrl = $"audio/{folder}/{fileName}", AudioUrl = $"audio/{folder}/{fileName}",
LyricsUrl = $"audio/{folder}/{nameWithoutExt}.txt", LyricsUrl = $"audio/{folder}/{nameWithoutExt}.txt",
CoverUrl = defaultCover CoverUrl = defaultCover
@@ -261,10 +263,11 @@ public class RadioLibraryService
private void ParseArtistAndTitle(string nameWithoutExt, out string artist, out string title) private void ParseArtistAndTitle(string nameWithoutExt, out string artist, out string title)
{ {
nameWithoutExt = Uri.UnescapeDataString(nameWithoutExt);
if (nameWithoutExt.Contains(" - ")) if (nameWithoutExt.Contains(" - "))
{ {
var parts = nameWithoutExt.Split(new[] { " - " }, 2, StringSplitOptions.TrimEntries); var parts = nameWithoutExt.Split(new[] { " - " }, 2, StringSplitOptions.TrimEntries);
artist = parts[0]; artist = FormatName(parts[0]);
title = FormatName(parts[1]); title = FormatName(parts[1]);
} }
else else
@@ -276,14 +279,17 @@ public class RadioLibraryService
private string FormatName(string raw) 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); return CultureInfo.CurrentCulture.TextInfo.ToTitleCase(cleaned);
} }
private string FormatStationName(string folderName) private string FormatStationName(string folderName)
{ {
// Replace underscore with space var unescaped = Uri.UnescapeDataString(folderName);
var clean = folderName.Replace('_', ' ').Trim(); var clean = unescaped.Replace('_', ' ').Trim();
clean = System.Text.RegularExpressions.Regex.Replace(clean, @"\s+", " ");
return CultureInfo.CurrentCulture.TextInfo.ToTitleCase(clean); return CultureInfo.CurrentCulture.TextInfo.ToTitleCase(clean);
} }
@@ -31,6 +31,7 @@ public class RadioStateService : IDisposable
public double CurrentTime { get; private set; } = 0; public double CurrentTime { get; private set; } = 0;
public double Duration { get; private set; } = 0; public double Duration { get; private set; } = 0;
public string CurrentLyrics { get; private set; } = string.Empty; public string CurrentLyrics { get; private set; } = string.Empty;
public bool IsAutoScrollEnabled { get; private set; } = true;
public RadioStateService(IJSRuntime js, RadioLibraryService library) public RadioStateService(IJSRuntime js, RadioLibraryService library)
{ {
@@ -223,11 +224,14 @@ public class RadioStateService : IDisposable
CurrentTrackIndex = (CurrentTrackIndex + 1) % TunedStation.Tracks.Count; CurrentTrackIndex = (CurrentTrackIndex + 1) % TunedStation.Tracks.Count;
CurrentTime = 0; CurrentTime = 0;
await LoadTrackDataAsync(); if (IsPoweredOn && IsTuned)
if (IsPoweredOn && IsPlaying)
{ {
await PlayCurrentTrackAsync(); await PlayCurrentTrackAsync();
} }
else
{
await LoadTrackDataAsync();
}
NotifyStateChanged(); NotifyStateChanged();
} }
@@ -238,11 +242,14 @@ public class RadioStateService : IDisposable
CurrentTrackIndex = (CurrentTrackIndex - 1 + TunedStation.Tracks.Count) % TunedStation.Tracks.Count; CurrentTrackIndex = (CurrentTrackIndex - 1 + TunedStation.Tracks.Count) % TunedStation.Tracks.Count;
CurrentTime = 0; CurrentTime = 0;
await LoadTrackDataAsync(); if (IsPoweredOn && IsTuned)
if (IsPoweredOn && IsPlaying)
{ {
await PlayCurrentTrackAsync(); await PlayCurrentTrackAsync();
} }
else
{
await LoadTrackDataAsync();
}
NotifyStateChanged(); NotifyStateChanged();
} }
@@ -277,6 +284,20 @@ public class RadioStateService : IDisposable
NotifyStateChanged(); 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] [JSInvokable]
public async Task SeekAsync(double seconds) public async Task SeekAsync(double seconds)
{ {
@@ -358,7 +379,19 @@ public class RadioStateService : IDisposable
[JSInvokable] [JSInvokable]
public async Task OnTrackEnded() 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] [JSInvokable]
+112 -1
View File
@@ -583,7 +583,7 @@ html, body {
font-family: var(--font-mono); font-family: var(--font-mono);
position: relative; position: relative;
overflow: hidden; overflow: hidden;
height: 155px; height: 235px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
box-shadow: box-shadow:
@@ -625,6 +625,74 @@ html, body {
margin-bottom: 4px; 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 { .vfd-track-title {
font-family: var(--font-display); font-family: var(--font-display);
font-size: 0.85rem; font-size: 0.85rem;
@@ -670,12 +738,55 @@ html, body {
.vfd-panel-header { .vfd-panel-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center;
font-size: 0.52rem; font-size: 0.52rem;
letter-spacing: 1px; letter-spacing: 1px;
color: var(--color-vfd-dim); color: var(--color-vfd-dim);
margin-bottom: 2px; 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 { .vfd-lyrics-container {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
+1 -1
View File
@@ -33,7 +33,7 @@
<span class="dismiss">🗙</span> <span class="dismiss">🗙</span>
</div> </div>
<script src="js/retro-radio.js?v=5"></script> <script src="js/retro-radio.js?v=6"></script>
<script src="_framework/blazor.webassembly#[.{fingerprint}].js"></script> <script src="_framework/blazor.webassembly#[.{fingerprint}].js"></script>
</body> </body>
+20 -5
View File
@@ -5,6 +5,7 @@ window.retroRadio = (() => {
let isPowered = true; let isPowered = true;
let currentVolume = 0.8; let currentVolume = 0.8;
let currentSignal = 1.0; let currentSignal = 1.0;
let isAutoScrollEnabled = true;
let isUserScrollingLyrics = false; let isUserScrollingLyrics = false;
let userScrollTimeout = null; let userScrollTimeout = null;
@@ -131,6 +132,7 @@ window.retroRadio = (() => {
if (audio.src !== absoluteUrl) { if (audio.src !== absoluteUrl) {
audio.src = absoluteUrl; audio.src = absoluteUrl;
} }
audio.currentTime = startTime;
updateAudioLevels(); updateAudioLevels();
@@ -183,15 +185,27 @@ window.retroRadio = (() => {
} }
function updateLyricsScroll(currentTime, duration) { function updateLyricsScroll(currentTime, duration) {
if (isUserScrollingLyrics || !duration || duration <= 0) return; if (!isAutoScrollEnabled || isUserScrollingLyrics || !duration || duration <= 0) return;
const container = document.querySelector(".vfd-lyrics-container"); const container = document.querySelector(".vfd-lyrics-container");
if (!container) return; if (!container) return;
const maxScroll = container.scrollHeight - container.clientHeight; const maxScroll = container.scrollHeight - container.clientHeight;
if (maxScroll > 0) { if (maxScroll <= 0) return;
const scrollRatio = Math.min(1.0, Math.max(0.0, currentTime / duration));
container.scrollTop = scrollRatio * maxScroll; 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() { function setupLyricsScrollListeners() {
@@ -252,6 +266,7 @@ window.retroRadio = (() => {
pauseTrack, pauseTrack,
seek, seek,
fadeToStatic, fadeToStatic,
updateTrackInfo updateTrackInfo,
setAutoScroll
}; };
})(); })();