Skip to content

Commit 133ab3b

Browse files
committed
feat: audio player visualizer
1 parent 4e5325e commit 133ab3b

2 files changed

Lines changed: 389 additions & 21 deletions

File tree

TelegramDownloader/Pages/Modals/AudioPlayerModal.razor

Lines changed: 244 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,59 @@
175175
100% { transform: translateX(-50%); }
176176
}
177177
178+
/* Loading Indicator */
179+
.audio-loading-indicator {
180+
height: 50px;
181+
background: linear-gradient(135deg, rgba(233, 69, 96, 0.05) 0%, rgba(15, 52, 96, 0.1) 100%);
182+
border-radius: 0.5rem;
183+
margin-bottom: 1rem;
184+
display: flex;
185+
flex-direction: column;
186+
align-items: center;
187+
justify-content: center;
188+
gap: 0.5rem;
189+
padding: 0.5rem;
190+
}
191+
192+
.loading-spinner {
193+
width: 20px;
194+
height: 20px;
195+
border: 2px solid rgba(233, 69, 96, 0.3);
196+
border-top-color: #e94560;
197+
border-radius: 50%;
198+
animation: spin 1s linear infinite;
199+
}
200+
201+
@@keyframes spin {
202+
to { transform: rotate(360deg); }
203+
}
204+
205+
.loading-text {
206+
color: rgba(255, 255, 255, 0.7);
207+
font-size: 0.75rem;
208+
}
209+
210+
.loading-progress {
211+
width: 80%;
212+
height: 4px;
213+
background: rgba(255, 255, 255, 0.1);
214+
border-radius: 2px;
215+
overflow: hidden;
216+
}
217+
218+
.loading-progress-bar {
219+
height: 100%;
220+
background: linear-gradient(90deg, #e94560 0%, #ff6b6b 100%);
221+
border-radius: 2px;
222+
transition: width 0.3s ease;
223+
}
224+
225+
.loading-percent {
226+
color: #e94560;
227+
font-size: 0.7rem;
228+
font-weight: 600;
229+
}
230+
178231
/* Visualizer */
179232
.audio-visualizer {
180233
height: 50px;
@@ -200,19 +253,27 @@
200253
animation: equalizer 0.8s ease-in-out infinite;
201254
}
202255
203-
.audio-visualizer .bar:nth-child(1) { height: 20%; animation-delay: 0s; }
204-
.audio-visualizer .bar:nth-child(2) { height: 40%; animation-delay: 0.1s; }
205-
.audio-visualizer .bar:nth-child(3) { height: 60%; animation-delay: 0.2s; }
206-
.audio-visualizer .bar:nth-child(4) { height: 80%; animation-delay: 0.3s; }
207-
.audio-visualizer .bar:nth-child(5) { height: 100%; animation-delay: 0.4s; }
208-
.audio-visualizer .bar:nth-child(6) { height: 80%; animation-delay: 0.5s; }
209-
.audio-visualizer .bar:nth-child(7) { height: 60%; animation-delay: 0.6s; }
210-
.audio-visualizer .bar:nth-child(8) { height: 40%; animation-delay: 0.7s; }
211-
.audio-visualizer .bar:nth-child(9) { height: 20%; animation-delay: 0.8s; }
212-
.audio-visualizer .bar:nth-child(10) { height: 40%; animation-delay: 0.9s; }
213-
.audio-visualizer .bar:nth-child(11) { height: 60%; animation-delay: 1s; }
214-
.audio-visualizer .bar:nth-child(12) { height: 80%; animation-delay: 1.1s; }
215-
.audio-visualizer .bar:nth-child(13) { height: 60%; animation-delay: 1.2s; }
256+
/* Default animation heights for non-real-time mode */
257+
.audio-visualizer:not(.real-time) .bar:nth-child(1) { height: 20%; animation-delay: 0s; }
258+
.audio-visualizer:not(.real-time) .bar:nth-child(2) { height: 40%; animation-delay: 0.1s; }
259+
.audio-visualizer:not(.real-time) .bar:nth-child(3) { height: 60%; animation-delay: 0.2s; }
260+
.audio-visualizer:not(.real-time) .bar:nth-child(4) { height: 80%; animation-delay: 0.3s; }
261+
.audio-visualizer:not(.real-time) .bar:nth-child(5) { height: 100%; animation-delay: 0.4s; }
262+
.audio-visualizer:not(.real-time) .bar:nth-child(6) { height: 80%; animation-delay: 0.5s; }
263+
.audio-visualizer:not(.real-time) .bar:nth-child(7) { height: 60%; animation-delay: 0.6s; }
264+
.audio-visualizer:not(.real-time) .bar:nth-child(8) { height: 40%; animation-delay: 0.7s; }
265+
.audio-visualizer:not(.real-time) .bar:nth-child(9) { height: 20%; animation-delay: 0.8s; }
266+
.audio-visualizer:not(.real-time) .bar:nth-child(10) { height: 40%; animation-delay: 0.9s; }
267+
.audio-visualizer:not(.real-time) .bar:nth-child(11) { height: 60%; animation-delay: 1s; }
268+
.audio-visualizer:not(.real-time) .bar:nth-child(12) { height: 80%; animation-delay: 1.1s; }
269+
.audio-visualizer:not(.real-time) .bar:nth-child(13) { height: 60%; animation-delay: 1.2s; }
270+
271+
/* Real-time visualizer - no animation, heights controlled by JS */
272+
.audio-visualizer.real-time .bar {
273+
animation: none;
274+
transition: height 0.05s ease-out;
275+
min-height: 5%;
276+
}
216277
217278
@@keyframes equalizer {
218279
0%, 100% { transform: scaleY(0.3); }
@@ -601,12 +662,32 @@
601662
</div>
602663
}
603664

604-
<div class="audio-visualizer @(isPlaying ? "" : "paused")">
605-
@for (int i = 0; i < 13; i++)
606-
{
607-
<div class="bar"></div>
608-
}
609-
</div>
665+
@if (isInitialLoading || isBuffering)
666+
{
667+
<div class="audio-loading-indicator">
668+
<div class="loading-spinner"></div>
669+
<span class="loading-text">@loadingStatus</span>
670+
@if (bufferPercent > 0 && bufferPercent < 100)
671+
{
672+
<div class="loading-progress">
673+
<div class="loading-progress-bar" style="width: @(bufferPercent.ToString("F0", System.Globalization.CultureInfo.InvariantCulture))%"></div>
674+
</div>
675+
<span class="loading-percent">@bufferPercent.ToString("F0")%</span>
676+
}
677+
</div>
678+
}
679+
else
680+
{
681+
<div class="audio-visualizer @(isPlaying ? "" : "paused") @(useRealVisualizer ? "real-time" : "")">
682+
@for (int i = 0; i < 13; i++)
683+
{
684+
var height = useRealVisualizer && visualizerBars != null && i < visualizerBars.Length
685+
? visualizerBars[i]
686+
: GetDefaultBarHeight(i);
687+
<div class="bar" style="@(useRealVisualizer ? $"height: {height.ToString("F0", System.Globalization.CultureInfo.InvariantCulture)}%" : "")"></div>
688+
}
689+
</div>
690+
}
610691

611692
<div class="audio-controls">
612693
<div class="audio-main-controls">
@@ -691,7 +772,10 @@
691772
style="display: none;"
692773
@onplay="OnPlay"
693774
@onpause="OnPause"
694-
@onended="OnEnded">
775+
@onended="OnEnded"
776+
@onwaiting="OnWaiting"
777+
@oncanplay="OnCanPlay"
778+
@onloadstart="OnLoadStart">
695779
</audio>
696780

697781
@code {
@@ -714,6 +798,16 @@
714798
private bool isLoadingArtwork = false;
715799
#pragma warning restore CS0414
716800

801+
// Loading/Buffering state
802+
private bool isBuffering = false;
803+
private bool isInitialLoading = false;
804+
private string loadingStatus = "";
805+
806+
// Real-time visualizer
807+
private bool useRealVisualizer = false;
808+
private double[] visualizerBars = new double[13];
809+
private CancellationTokenSource? visualizerCts;
810+
717811
// Playlist
718812
private List<PlaylistItem> playlist = new();
719813
private int currentTrackIndex = -1;
@@ -767,8 +861,16 @@
767861
{
768862
isPlaying = false;
769863
progressPercent = 0;
864+
bufferPercent = 0;
770865
currentTimeDisplay = "0:00";
771866
artworkUrl = null; // Reset artwork before loading new one
867+
868+
// Set loading state - check if it's a Telegram file (not local)
869+
bool isTelegramFile = audioUrl.Contains("/api/file/") || audioUrl.Contains("GetFileStream");
870+
isInitialLoading = isTelegramFile;
871+
isBuffering = false;
872+
loadingStatus = isTelegramFile ? "Loading from Telegram..." : "";
873+
772874
await JSRuntime.InvokeVoidAsync("playAudioPlayer", audioUrl, audioType);
773875

774876
// Extract artwork and update Media Session (async, won't block playback)
@@ -957,6 +1059,7 @@
9571059
try
9581060
{
9591061
isPlaying = true;
1062+
await StartRealVisualizer();
9601063
await InvokeAsync(StateHasChanged);
9611064
}
9621065
catch { }
@@ -967,11 +1070,82 @@
9671070
try
9681071
{
9691072
isPlaying = false;
1073+
StopRealVisualizer();
9701074
await InvokeAsync(StateHasChanged);
9711075
}
9721076
catch { }
9731077
}
9741078

1079+
private int GetDefaultBarHeight(int index)
1080+
{
1081+
// Default animated heights when not using real visualizer
1082+
int[] defaultHeights = { 20, 40, 60, 80, 100, 80, 60, 40, 20, 40, 60, 80, 60 };
1083+
return index < defaultHeights.Length ? defaultHeights[index] : 50;
1084+
}
1085+
1086+
private async Task StartRealVisualizer()
1087+
{
1088+
try
1089+
{
1090+
// Initialize the audio visualizer in JavaScript
1091+
var initialized = await JSRuntime.InvokeAsync<bool>("initAudioVisualizer");
1092+
if (initialized)
1093+
{
1094+
useRealVisualizer = true;
1095+
visualizerCts?.Cancel();
1096+
visualizerCts = new CancellationTokenSource();
1097+
_ = UpdateVisualizerLoop(visualizerCts.Token);
1098+
}
1099+
}
1100+
catch
1101+
{
1102+
useRealVisualizer = false;
1103+
}
1104+
}
1105+
1106+
private void StopRealVisualizer()
1107+
{
1108+
visualizerCts?.Cancel();
1109+
// Reset bars to minimum
1110+
for (int i = 0; i < visualizerBars.Length; i++)
1111+
{
1112+
visualizerBars[i] = 5;
1113+
}
1114+
}
1115+
1116+
private async Task UpdateVisualizerLoop(CancellationToken token)
1117+
{
1118+
while (!token.IsCancellationRequested && !_disposed && isPlaying)
1119+
{
1120+
try
1121+
{
1122+
var data = await JSRuntime.InvokeAsync<double[]>("getVisualizerData", token);
1123+
if (data != null && data.Length >= 13)
1124+
{
1125+
for (int i = 0; i < 13 && i < data.Length; i++)
1126+
{
1127+
// Add minimum height and smooth the values
1128+
visualizerBars[i] = Math.Max(5, data[i]);
1129+
}
1130+
await InvokeAsync(StateHasChanged);
1131+
}
1132+
await Task.Delay(50, token); // ~20 FPS update rate
1133+
}
1134+
catch (TaskCanceledException)
1135+
{
1136+
break;
1137+
}
1138+
catch (ObjectDisposedException)
1139+
{
1140+
break;
1141+
}
1142+
catch
1143+
{
1144+
// Ignore other errors, continue loop
1145+
}
1146+
}
1147+
}
1148+
9751149
private async void OnEnded()
9761150
{
9771151
try
@@ -986,6 +1160,45 @@
9861160
catch { }
9871161
}
9881162

1163+
private async void OnWaiting()
1164+
{
1165+
try
1166+
{
1167+
isBuffering = true;
1168+
loadingStatus = "Buffering...";
1169+
await InvokeAsync(StateHasChanged);
1170+
}
1171+
catch { }
1172+
}
1173+
1174+
private async void OnCanPlay()
1175+
{
1176+
try
1177+
{
1178+
isBuffering = false;
1179+
isInitialLoading = false;
1180+
loadingStatus = "";
1181+
await InvokeAsync(StateHasChanged);
1182+
}
1183+
catch { }
1184+
}
1185+
1186+
private async void OnLoadStart()
1187+
{
1188+
try
1189+
{
1190+
// Only show loading for Telegram files
1191+
bool isTelegramFile = audioUrl.Contains("/api/file/") || audioUrl.Contains("GetFileStream");
1192+
if (isTelegramFile)
1193+
{
1194+
isInitialLoading = true;
1195+
loadingStatus = "Loading from Telegram...";
1196+
await InvokeAsync(StateHasChanged);
1197+
}
1198+
}
1199+
catch { }
1200+
}
1201+
9891202
// Playlist methods
9901203
private async Task PlayTrack(int index)
9911204
{
@@ -997,11 +1210,19 @@
9971210
audioType = track.Type;
9981211
audioTitle = track.Title;
9991212
progressPercent = 0;
1213+
bufferPercent = 0;
10001214
currentTimeDisplay = "0:00";
10011215
artworkUrl = null; // Reset artwork before loading new one
1216+
1217+
// Set loading state - check if it's a Telegram file (not local)
1218+
bool isTelegramFile = audioUrl.Contains("/api/file/") || audioUrl.Contains("GetFileStream");
1219+
isInitialLoading = isTelegramFile;
1220+
isBuffering = false;
1221+
loadingStatus = isTelegramFile ? "Loading from Telegram..." : "";
1222+
10021223
await JSRuntime.InvokeVoidAsync("playAudioPlayer", audioUrl, audioType);
10031224

1004-
StateHasChanged();
1225+
await InvokeAsync(StateHasChanged);
10051226

10061227
// Extract artwork from audio file (async, won't block playback)
10071228
_ = ExtractArtworkAndUpdateMediaSession(audioUrl);
@@ -1379,5 +1600,7 @@
13791600
{
13801601
_disposed = true;
13811602
StopProgressTimer();
1603+
StopRealVisualizer();
1604+
visualizerCts?.Dispose();
13821605
}
13831606
}

0 commit comments

Comments
 (0)