|
175 | 175 | 100% { transform: translateX(-50%); } |
176 | 176 | } |
177 | 177 |
|
| 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 | +
|
178 | 231 | /* Visualizer */ |
179 | 232 | .audio-visualizer { |
180 | 233 | height: 50px; |
|
200 | 253 | animation: equalizer 0.8s ease-in-out infinite; |
201 | 254 | } |
202 | 255 |
|
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 | + } |
216 | 277 |
|
217 | 278 | @@keyframes equalizer { |
218 | 279 | 0%, 100% { transform: scaleY(0.3); } |
|
601 | 662 | </div> |
602 | 663 | } |
603 | 664 |
|
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 | + } |
610 | 691 |
|
611 | 692 | <div class="audio-controls"> |
612 | 693 | <div class="audio-main-controls"> |
|
691 | 772 | style="display: none;" |
692 | 773 | @onplay="OnPlay" |
693 | 774 | @onpause="OnPause" |
694 | | - @onended="OnEnded"> |
| 775 | + @onended="OnEnded" |
| 776 | + @onwaiting="OnWaiting" |
| 777 | + @oncanplay="OnCanPlay" |
| 778 | + @onloadstart="OnLoadStart"> |
695 | 779 | </audio> |
696 | 780 |
|
697 | 781 | @code { |
|
714 | 798 | private bool isLoadingArtwork = false; |
715 | 799 | #pragma warning restore CS0414 |
716 | 800 |
|
| 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 | + |
717 | 811 | // Playlist |
718 | 812 | private List<PlaylistItem> playlist = new(); |
719 | 813 | private int currentTrackIndex = -1; |
|
767 | 861 | { |
768 | 862 | isPlaying = false; |
769 | 863 | progressPercent = 0; |
| 864 | + bufferPercent = 0; |
770 | 865 | currentTimeDisplay = "0:00"; |
771 | 866 | 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 | + |
772 | 874 | await JSRuntime.InvokeVoidAsync("playAudioPlayer", audioUrl, audioType); |
773 | 875 |
|
774 | 876 | // Extract artwork and update Media Session (async, won't block playback) |
|
957 | 1059 | try |
958 | 1060 | { |
959 | 1061 | isPlaying = true; |
| 1062 | + await StartRealVisualizer(); |
960 | 1063 | await InvokeAsync(StateHasChanged); |
961 | 1064 | } |
962 | 1065 | catch { } |
|
967 | 1070 | try |
968 | 1071 | { |
969 | 1072 | isPlaying = false; |
| 1073 | + StopRealVisualizer(); |
970 | 1074 | await InvokeAsync(StateHasChanged); |
971 | 1075 | } |
972 | 1076 | catch { } |
973 | 1077 | } |
974 | 1078 |
|
| 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 | + |
975 | 1149 | private async void OnEnded() |
976 | 1150 | { |
977 | 1151 | try |
|
986 | 1160 | catch { } |
987 | 1161 | } |
988 | 1162 |
|
| 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 | + |
989 | 1202 | // Playlist methods |
990 | 1203 | private async Task PlayTrack(int index) |
991 | 1204 | { |
|
997 | 1210 | audioType = track.Type; |
998 | 1211 | audioTitle = track.Title; |
999 | 1212 | progressPercent = 0; |
| 1213 | + bufferPercent = 0; |
1000 | 1214 | currentTimeDisplay = "0:00"; |
1001 | 1215 | 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 | + |
1002 | 1223 | await JSRuntime.InvokeVoidAsync("playAudioPlayer", audioUrl, audioType); |
1003 | 1224 |
|
1004 | | - StateHasChanged(); |
| 1225 | + await InvokeAsync(StateHasChanged); |
1005 | 1226 |
|
1006 | 1227 | // Extract artwork from audio file (async, won't block playback) |
1007 | 1228 | _ = ExtractArtworkAndUpdateMediaSession(audioUrl); |
|
1379 | 1600 | { |
1380 | 1601 | _disposed = true; |
1381 | 1602 | StopProgressTimer(); |
| 1603 | + StopRealVisualizer(); |
| 1604 | + visualizerCts?.Dispose(); |
1382 | 1605 | } |
1383 | 1606 | } |
0 commit comments