changes
This commit is contained in:
@@ -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,19 +129,52 @@
|
||||
</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;
|
||||
}
|
||||
|
||||
private void HandleStateChanged()
|
||||
{
|
||||
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,8 +379,20 @@ public class RadioStateService : IDisposable
|
||||
[JSInvokable]
|
||||
public async Task OnTrackEnded()
|
||||
{
|
||||
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]
|
||||
public void OnAudioStateChanged(bool isPlaying)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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));
|
||||
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
|
||||
};
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user