diff --git a/PlaylistShared.Api/Controllers/AudioController.cs b/PlaylistShared.Api/Controllers/AudioController.cs new file mode 100644 index 0000000..d2afe87 --- /dev/null +++ b/PlaylistShared.Api/Controllers/AudioController.cs @@ -0,0 +1,84 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using PlaylistShared.Api.Entities; +using PlaylistShared.Api.Services; +using System.Security.Claims; + +namespace PlaylistShared.Api.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class AudioController : ControllerBase +{ + private readonly UserManager _userManager; + private readonly YandexMusicService _yandexService; + private readonly JwtService _jwtService; + + public AudioController( + UserManager userManager, + YandexMusicService yandexService, + JwtService jwtService) + { + _userManager = userManager; + _yandexService = yandexService; + _jwtService = jwtService; + } + + /// + /// Потоковое воспроизведение трека из Яндекс.Музыки. + /// + /// ID трека (например, "21696942"). + [HttpGet("track/{trackId}")] + [AllowAnonymous] + public async Task StreamTrack(string trackId, [FromQuery] string? access_token = null) + { + var user = await GetUserFromToken(access_token); + if (user == null) + return Unauthorized(); + + var streamUrl = await _yandexService.GetTrackFileUrlAsync(user, trackId); + if (string.IsNullOrEmpty(streamUrl)) + return NotFound(); + + var httpClient = new HttpClient(); + var request = new HttpRequestMessage(HttpMethod.Get, streamUrl); + + // Пробрасываем Range-заголовок клиента к Яндекс.Музыке + if (Request.Headers.ContainsKey("Range")) + { + request.Headers.Add("Range", Request.Headers["Range"].ToString()); + } + var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); + + // Если Яндекс.Музыка поддерживает range, пробрасываем статус 206 + Response.StatusCode = (int)response.StatusCode; + Response.ContentType = response.Content.Headers.ContentType?.ToString() ?? "audio/mpeg"; + + if (response.Content.Headers.Contains("Content-Range")) + Response.Headers.Add("Content-Range", response.Content.Headers.ContentRange?.ToString()); + if (response.Headers.Contains("Accept-Ranges")) + Response.Headers.Add("Accept-Ranges", response.Headers.AcceptRanges?.ToString()); + if (response.Content.Headers.Contains("Content-Length")) + Response.Headers.Add("Content-Length", response.Content.Headers.ContentLength?.ToString()); + + await response.Content.CopyToAsync(Response.Body); + return new EmptyResult(); + } + + private async Task GetUserFromToken(string? token) + { + if (string.IsNullOrEmpty(token)) + return null; + + var principal = _jwtService.ValidateToken(token); + if (principal == null) + return null; + + var userId = principal.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(userId)) + return null; + + return await _userManager.FindByIdAsync(userId); + } +} \ No newline at end of file diff --git a/PlaylistShared.Api/PlaylistShared.Api.csproj b/PlaylistShared.Api/PlaylistShared.Api.csproj index 9d47615..c71500b 100644 --- a/PlaylistShared.Api/PlaylistShared.Api.csproj +++ b/PlaylistShared.Api/PlaylistShared.Api.csproj @@ -27,7 +27,7 @@ - + diff --git a/PlaylistShared.Api/Services/JwtService.cs b/PlaylistShared.Api/Services/JwtService.cs index 76e243f..1adb510 100644 --- a/PlaylistShared.Api/Services/JwtService.cs +++ b/PlaylistShared.Api/Services/JwtService.cs @@ -46,4 +46,29 @@ public class JwtService return (tokenString, refreshToken, tokenDescriptor.Expires.Value); } + + public ClaimsPrincipal? ValidateToken(string token) + { + var tokenHandler = new JwtSecurityTokenHandler(); + var key = Encoding.UTF8.GetBytes(_configuration["Jwt:Key"]!); + try + { + var principal = tokenHandler.ValidateToken(token, new TokenValidationParameters + { + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(key), + ValidateIssuer = true, + ValidIssuer = _configuration["Jwt:Issuer"], + ValidateAudience = true, + ValidAudience = _configuration["Jwt:Audience"], + ValidateLifetime = true, + ClockSkew = TimeSpan.Zero + }, out _); + return principal; + } + catch + { + return null; + } + } } \ No newline at end of file diff --git a/PlaylistShared.Api/Services/YandexMusicService.cs b/PlaylistShared.Api/Services/YandexMusicService.cs index 2d95079..97ccf6e 100644 --- a/PlaylistShared.Api/Services/YandexMusicService.cs +++ b/PlaylistShared.Api/Services/YandexMusicService.cs @@ -72,6 +72,15 @@ public class YandexMusicService return await playlist.RemoveTracksAsync(tracks.ToArray()); } + public async Task GetTrackFileUrlAsync(ApplicationUser user, string trackId) + { + using var client = await CreateClientAsync(user); + if (client == null) return null; + var track = await client.GetTrackAsync(trackId); + if (track == null) return null; + return await track.GetLinkAsync(); + } + public string EncryptToken(string token) => _dataProtector.Protect(token); public string DecryptToken(string encryptedToken) diff --git a/PlaylistShared.Pwa/Components/AudioPlayer.razor b/PlaylistShared.Pwa/Components/AudioPlayer.razor new file mode 100644 index 0000000..b750e4e --- /dev/null +++ b/PlaylistShared.Pwa/Components/AudioPlayer.razor @@ -0,0 +1,300 @@ +@using Microsoft.JSInterop +@using Microsoft.AspNetCore.Components.Authorization +@namespace PlaylistShared.Pwa.Components +@inject HttpClient Http + + +
+
+ + +
+ +
+ +
+ +
+ @_currentTime / @_totalTime +
+ + +
+
+
+
+ + + +@code { + private string _audioId = $"audio_{Guid.NewGuid():N}"; + private IJSObjectReference? _audioModule; + private IJSObjectReference? _audioElement; + private double _currentProgress; + private double _currentVolume = 70; + private string _currentTime = "0:00"; + private string _totalTime = "0:00"; + private bool _isPlaying; + private Timer? _progressTimer; + private bool _isMuted; + + [Inject] protected IJSRuntime JS { get; set; } = null!; + [Inject] private TokenStorage TokenStorage { get; set; } = null!; + [Inject] private AuthenticationStateProvider AuthProvider { get; set; } = null!; + [Inject] private ISnackbar Snackbar { get; set; } = null!; + + /// Требовать ли авторизацию для воспроизведения (по умолчанию true). + [Parameter] public bool RequireAuth { get; set; } = true; + + /// Событие при завершении трека. + [Parameter] public EventCallback OnTrackEnded { get; set; } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + await EnsureAudioModuleAsync(); + } + } + + [JSInvokable] + public async Task OnAudioEnded() + { + _isPlaying = false; + _currentProgress = 0; + StopProgressTimer(); + if (OnTrackEnded.HasDelegate) + await OnTrackEnded.InvokeAsync(); + StateHasChanged(); + } + + [JSInvokable] + public async Task OnTimeUpdate(double currentTime, double duration) + { + // Защита от некорректных значений + if (double.IsNaN(currentTime) || double.IsNaN(duration) || double.IsInfinity(currentTime) || double.IsInfinity(duration)) + return; + if (duration <= 0) return; + + _currentProgress = (currentTime / duration) * 100; + _currentTime = FormatTime(currentTime); + // Длительность не обновляем здесь + await InvokeAsync(StateHasChanged); + } + + [JSInvokable] + public async Task OnDurationReady(double duration) + { + if (double.IsNaN(duration) || double.IsInfinity(duration) || duration <= 0) return; + _totalTime = FormatTime(duration); + await InvokeAsync(StateHasChanged); + } + + private async Task EnsureAudioModuleAsync() + { + if (_audioModule == null) + _audioModule = await JS.InvokeAsync("import", "/js/AudioPlayer.js"); + if (_audioElement == null) + _audioElement = await _audioModule.InvokeAsync("init", _audioId, DotNetObjectReference.Create(this)); + } + + private async Task CheckAuthAsync() + { + if (!RequireAuth) return true; + + var authState = await AuthProvider.GetAuthenticationStateAsync(); + var isAuthenticated = authState.User.Identity?.IsAuthenticated == true; + + if (!isAuthenticated) + { + Snackbar.Add("Воспроизведение доступно только авторизованным пользователям", Severity.Warning); + return false; + } + return true; + } + + public async Task LoadAndPlayAsync(string trackId) + { + if (!await CheckAuthAsync()) return; + + var tokens = await TokenStorage.GetTokensAsync(); + var accessToken = tokens.token; + + if (string.IsNullOrEmpty(accessToken)) + { + Snackbar.Add("Токен авторизации не найден. Пожалуйста, войдите заново.", Severity.Error); + return; + } + + var streamUrl = new Uri(Http.BaseAddress, $"/api/audio/track/{trackId}").ToString(); + + await EnsureAudioModuleAsync(); + await _audioElement!.InvokeVoidAsync("loadAndPlay", streamUrl, accessToken); + _isPlaying = true; + StartProgressTimer(); + StateHasChanged(); + } + + public async Task PlayAsync() + { + if (!await CheckAuthAsync()) return; + + if (_audioElement == null) return; + await _audioElement.InvokeVoidAsync("play"); + _isPlaying = true; + StartProgressTimer(); + StateHasChanged(); + } + + public async Task PauseAsync() + { + if (_audioElement == null) return; + await _audioElement.InvokeVoidAsync("pause"); + _isPlaying = false; + StopProgressTimer(); + StateHasChanged(); + } + + public async Task StopAsync() + { + if (_audioElement == null) return; + await _audioElement.InvokeVoidAsync("stop"); + _isPlaying = false; + _currentProgress = 0; + StopProgressTimer(); + StateHasChanged(); + } + + private async Task TogglePlayPause() + { + if (_isPlaying) + await PauseAsync(); + else + await PlayAsync(); + } + + private async Task Stop() + { + await StopAsync(); + } + + private async Task SeekTo(double value) + { + if (_audioElement == null) return; + try + { + var duration = await _audioElement.InvokeAsync("getDuration"); + if (duration > 0 && !double.IsNaN(duration)) + { + var newTime = (value / 100) * duration; + await _audioElement.InvokeVoidAsync("setCurrentTime", newTime); + } + } + catch (Exception ex) + { + Console.WriteLine($"SeekTo error: {ex.Message}"); + } + } + + private async Task ChangeVolume(double value) + { + if (_audioElement == null) return; + try + { + var volume = value / 100; + await _audioElement.InvokeVoidAsync("setVolume", volume); + _isMuted = false; + StateHasChanged(); + } + catch (Exception ex) + { + Console.WriteLine($"ChangeVolume error: {ex.Message}"); + } + } + + private async Task ToggleMute() + { + if (_audioElement == null) return; + _isMuted = !_isMuted; + var newVolume = _isMuted ? 0 : (_currentVolume / 100); + await _audioElement.InvokeVoidAsync("setVolume", newVolume); + StateHasChanged(); + } + + private void StartProgressTimer() + { + StopProgressTimer(); + _progressTimer = new Timer(async _ => + { + await UpdateProgress(); + }, null, 0, 500); + } + + private void StopProgressTimer() => _progressTimer?.Dispose(); + + private async Task UpdateProgress() + { + if (_audioElement == null) + { + Console.WriteLine("UpdateProgress: _audioElement is null"); + return; + } + try + { + var current = await _audioElement.InvokeAsync("getCurrentTime"); + var duration = await _audioElement.InvokeAsync("getDuration"); + + if (duration > 0 && !double.IsNaN(duration) && !double.IsNaN(current)) + { + _currentProgress = (current / duration) * 100; + _currentTime = FormatTime(current); + _totalTime = FormatTime(duration); + await InvokeAsync(StateHasChanged); + } + } + catch (Exception ex) + { + Console.WriteLine($"UpdateProgress error: {ex.Message}"); + } + } + + private string FormatTime(double seconds) + { + var total = (int)seconds; + var mins = total / 60; + var secs = total % 60; + return $"{mins}:{secs:D2}"; + } + + public async ValueTask DisposeAsync() + { + try + { + StopProgressTimer(); + if (_audioElement != null) + await _audioElement.DisposeAsync(); + if (_audioModule != null) + await _audioModule.DisposeAsync(); + } + catch { } + } +} \ No newline at end of file diff --git a/PlaylistShared.Pwa/Components/TrackCoverWithPlay.razor b/PlaylistShared.Pwa/Components/TrackCoverWithPlay.razor new file mode 100644 index 0000000..82d6a50 --- /dev/null +++ b/PlaylistShared.Pwa/Components/TrackCoverWithPlay.razor @@ -0,0 +1,50 @@ +@using Microsoft.AspNetCore.Components.Web + +
+ + + + @if (_isHovered || IsPlaying) + { +
+ +
+ } +
+ +@code { + [Parameter] public string CoverUrl { get; set; } = string.Empty; + [Parameter] public string TrackId { get; set; } = string.Empty; + [Parameter] public bool IsPlaying { get; set; } = false; + [Parameter] public EventCallback OnPlay { get; set; } + [Parameter] public int Height { get; set; } = 50; + [Parameter] public int Width { get; set; } = 50; + + private bool _isHovered; + + private void HandleMouseEnter() => _isHovered = true; + private void HandleMouseLeave() => _isHovered = false; + + private async Task OnPlayClick() + { + await OnPlay.InvokeAsync(TrackId); + } + + private string FormatCoverUrl(string? url) + { + if (string.IsNullOrEmpty(url)) return ""; + return "https://" + url.Replace("%%", $"{Width}x{Height}"); + } +} \ No newline at end of file diff --git a/PlaylistShared.Pwa/Pages/SharedPlaylistView.razor b/PlaylistShared.Pwa/Pages/SharedPlaylistView.razor index e8f8744..97ce21e 100644 --- a/PlaylistShared.Pwa/Pages/SharedPlaylistView.razor +++ b/PlaylistShared.Pwa/Pages/SharedPlaylistView.razor @@ -143,7 +143,12 @@ @if (!string.IsNullOrEmpty(context.CoverUri)) { - + } @@ -165,15 +170,20 @@ } + } } -@code { + @code { [Parameter] public string Token { get; set; } + private AudioPlayer? _audioPlayer; + private string? _currentTrackId { get; set; } + private bool _isPlaying = false; + private SharedPlaylistDto? _playlist; private bool _loading = true; private bool _isAuthenticated; @@ -215,7 +225,7 @@ _canRemove = _isCreator || _playlist.RemovePermission == EditPermission.Everyone || (_playlist.RemovePermission == EditPermission.AuthorizedOnly && _isAuthenticated); - + if (_isCreator && _isAuthenticated) { _editPermissions = new UpdatePermissionsDto @@ -402,4 +412,36 @@ { public int Index { get; set; } } + + private async Task PlayTrack(string trackId) + { + if (_audioPlayer == null) return; + + if (_currentTrackId == trackId && _isPlaying) + { + await _audioPlayer.PauseAsync(); + _isPlaying = false; + } + else if (_currentTrackId == trackId && !_isPlaying) + { + await _audioPlayer.PlayAsync(); + _isPlaying = true; + } + else + { + if (!string.IsNullOrEmpty(_currentTrackId) && _isPlaying) + await _audioPlayer.StopAsync(); + + _currentTrackId = trackId; + await _audioPlayer.LoadAndPlayAsync(trackId); + _isPlaying = true; + } + } + + private async Task OnTrackEnded() + { + _currentTrackId = null; + _isPlaying = false; + StateHasChanged(); + } } \ No newline at end of file diff --git a/PlaylistShared.Pwa/PlaylistShared.Pwa.csproj b/PlaylistShared.Pwa/PlaylistShared.Pwa.csproj index ae4521f..4fb904a 100644 --- a/PlaylistShared.Pwa/PlaylistShared.Pwa.csproj +++ b/PlaylistShared.Pwa/PlaylistShared.Pwa.csproj @@ -24,4 +24,10 @@
+ + + Always + + + diff --git a/PlaylistShared.Pwa/Program.cs b/PlaylistShared.Pwa/Program.cs index 89f928b..8c7764a 100644 --- a/PlaylistShared.Pwa/Program.cs +++ b/PlaylistShared.Pwa/Program.cs @@ -25,14 +25,6 @@ internal class Program builder.Services.AddScoped(sp => sp.GetRequiredService()); builder.Services.AddScoped(); - /* - builder.Services.AddOidcAuthentication(options => - { - // Configure your authentication provider options here. - // For more information, see https://aka.ms/blazor-standalone-auth - builder.Configuration.Bind("Local", options.ProviderOptions); - }); - */ builder.Services.AddAuthorizationCore(); builder.Services.AddCascadingAuthenticationState(); diff --git a/PlaylistShared.Pwa/_Imports.razor b/PlaylistShared.Pwa/_Imports.razor index 509ffb6..e267f29 100644 --- a/PlaylistShared.Pwa/_Imports.razor +++ b/PlaylistShared.Pwa/_Imports.razor @@ -12,4 +12,5 @@ @using PlaylistShared.Pwa.Services @using MudBlazor @using Microsoft.AspNetCore.Authorization -@using PlaylistShared.Shared \ No newline at end of file +@using PlaylistShared.Shared +@using PlaylistShared.Pwa.Components \ No newline at end of file diff --git a/PlaylistShared.Pwa/wwwroot/css/app.css b/PlaylistShared.Pwa/wwwroot/css/app.css index e24afa9..ab073c4 100644 --- a/PlaylistShared.Pwa/wwwroot/css/app.css +++ b/PlaylistShared.Pwa/wwwroot/css/app.css @@ -108,3 +108,18 @@ code { .form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder { text-align: start; } + +.track-cover-container { + border-radius: 4px; + overflow: hidden; + transition: transform 0.2s ease; +} + + .track-cover-container:hover { + transform: scale(1.05); + } + +.play-overlay { + transition: opacity 0.2s ease; + cursor: pointer; +} \ No newline at end of file diff --git a/PlaylistShared.Pwa/wwwroot/js/AudioPlayer.js b/PlaylistShared.Pwa/wwwroot/js/AudioPlayer.js new file mode 100644 index 0000000..8d373c7 --- /dev/null +++ b/PlaylistShared.Pwa/wwwroot/js/AudioPlayer.js @@ -0,0 +1,54 @@ +export function init(audioId, dotNetHelper) { + const audio = document.getElementById(audioId); + if (!audio) throw new Error(`Audio element with id ${audioId} not found`); + + let durationReady = false; + let durationValue = 0; + + const toNumber = (val) => { + const num = Number(val); + return isNaN(num) ? 0 : num; + }; + + const loadAndPlay = (src, token) => { + const url = new URL(src, window.location.href); + if (token) url.searchParams.set('access_token', token); + audio.src = url.toString(); + audio.load(); + durationReady = false; + durationValue = 0; + audio.play().catch(e => console.error('Play failed:', e)); + }; + + const play = () => audio.play(); + const pause = () => audio.pause(); + const stop = () => { audio.pause(); audio.currentTime = 0; }; + const setVolume = (volume) => { audio.volume = toNumber(volume); }; + const setCurrentTime = (time) => { audio.currentTime = toNumber(time); }; + const getDuration = () => durationReady ? durationValue : 0; + const getCurrentTime = () => toNumber(audio.currentTime); + + audio.addEventListener('loadedmetadata', () => { + durationValue = toNumber(audio.duration); + durationReady = durationValue > 0; + if (dotNetHelper && durationReady) { + dotNetHelper.invokeMethodAsync('OnDurationReady', durationValue); + } + }); + + audio.addEventListener('timeupdate', () => { + if (dotNetHelper && durationReady) { + const current = toNumber(audio.currentTime); + dotNetHelper.invokeMethodAsync('OnTimeUpdate', current, durationValue); + } + }); + + audio.addEventListener('ended', () => { + if (dotNetHelper) { + dotNetHelper.invokeMethodAsync('OnAudioEnded'); + } + }); + + // Возвращаем все методы, которые будут вызываться из C# + return { loadAndPlay, play, pause, stop, setVolume, setCurrentTime, getDuration, getCurrentTime }; +} \ No newline at end of file