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">
@if (RadioState.Duration > 0)
{
<span>@FormatTime(RadioState.CurrentTime) / @FormatTime(RadioState.Duration)</span>
<span>@FormatTime(DisplayTime) / @FormatTime(RadioState.Duration)</span>
}
else
{
@@ -38,6 +38,23 @@
</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">
@if (RadioState.IsPoweredOn && RadioState.CurrentTrack != null && RadioState.IsTuned)
{
@@ -81,7 +98,12 @@
<div class="vfd-lyrics-panel">
<div class="vfd-panel-header">
<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 class="vfd-lyrics-container">
@@ -107,6 +129,15 @@
</div>
@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}";
}
@@ -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);
}
@@ -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]
+112 -1
View File
@@ -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;
+1 -1
View File
@@ -33,7 +33,7 @@
<span class="dismiss">🗙</span>
</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>
</body>
+20 -5
View File
@@ -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
};
})();