Плеер
This commit is contained in:
84
PlaylistShared.Api/Controllers/AudioController.cs
Normal file
84
PlaylistShared.Api/Controllers/AudioController.cs
Normal file
@@ -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<ApplicationUser> _userManager;
|
||||||
|
private readonly YandexMusicService _yandexService;
|
||||||
|
private readonly JwtService _jwtService;
|
||||||
|
|
||||||
|
public AudioController(
|
||||||
|
UserManager<ApplicationUser> userManager,
|
||||||
|
YandexMusicService yandexService,
|
||||||
|
JwtService jwtService)
|
||||||
|
{
|
||||||
|
_userManager = userManager;
|
||||||
|
_yandexService = yandexService;
|
||||||
|
_jwtService = jwtService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Потоковое воспроизведение трека из Яндекс.Музыки.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="trackId">ID трека (например, "21696942").</param>
|
||||||
|
[HttpGet("track/{trackId}")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
public async Task<IActionResult> 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<ApplicationUser?> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore.Swagger" Version="10.1.7" />
|
<PackageReference Include="Swashbuckle.AspNetCore.Swagger" Version="10.1.7" />
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.1.7" />
|
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.1.7" />
|
||||||
<PackageReference Include="YandexMusic" Version="0.0.5" />
|
<PackageReference Include="YandexMusic" Version="0.0.6" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -46,4 +46,29 @@ public class JwtService
|
|||||||
|
|
||||||
return (tokenString, refreshToken, tokenDescriptor.Expires.Value);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -72,6 +72,15 @@ public class YandexMusicService
|
|||||||
return await playlist.RemoveTracksAsync(tracks.ToArray());
|
return await playlist.RemoveTracksAsync(tracks.ToArray());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<string?> 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 EncryptToken(string token) => _dataProtector.Protect(token);
|
||||||
|
|
||||||
public string DecryptToken(string encryptedToken)
|
public string DecryptToken(string encryptedToken)
|
||||||
|
|||||||
300
PlaylistShared.Pwa/Components/AudioPlayer.razor
Normal file
300
PlaylistShared.Pwa/Components/AudioPlayer.razor
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
@using Microsoft.JSInterop
|
||||||
|
@using Microsoft.AspNetCore.Components.Authorization
|
||||||
|
@namespace PlaylistShared.Pwa.Components
|
||||||
|
@inject HttpClient Http
|
||||||
|
|
||||||
|
<MudPaper Class="pa-4" Elevation="0" Style="background-color: rgba(0,0,0,0.05); border-radius: 8px;">
|
||||||
|
<div style="display: flex; align-items: center; gap: 12px; flex-wrap: wrap;">
|
||||||
|
<div style="display: flex; gap: 8px;">
|
||||||
|
<MudIconButton Icon="@(_isPlaying? Icons.Material.Filled.Pause : Icons.Material.Filled.PlayArrow)"
|
||||||
|
Size="Size.Medium"
|
||||||
|
Color="Color.Primary"
|
||||||
|
OnClick="TogglePlayPause" />
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.Stop"
|
||||||
|
Size="Size.Medium"
|
||||||
|
Color="Color.Default"
|
||||||
|
OnClick="Stop" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="flex-grow: 1; min-width: 150px;">
|
||||||
|
<MudSlider @bind-Value="_currentProgress"
|
||||||
|
@bind-Value:event="oninput"
|
||||||
|
Min="0"
|
||||||
|
Max="100"
|
||||||
|
Size="Size.Small"
|
||||||
|
ValueChanged="@((double newValue) => SeekTo(newValue))" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; align-items: center; gap: 12px;">
|
||||||
|
<MudText Typo="Typo.body2">@_currentTime / @_totalTime</MudText>
|
||||||
|
<div style="display: flex; align-items: center; gap: 8px; width: 120px;">
|
||||||
|
<MudIconButton Icon="@(_currentVolume == 0 ? Icons.Material.Filled.VolumeOff : Icons.Material.Filled.VolumeUp)"
|
||||||
|
Size="Size.Small"
|
||||||
|
Color="Color.Default"
|
||||||
|
OnClick="ToggleMute" />
|
||||||
|
<MudSlider @bind-Value="_currentVolume"
|
||||||
|
@bind-Value:event="oninput"
|
||||||
|
Min="0"
|
||||||
|
Max="100"
|
||||||
|
Size="Size.Small"
|
||||||
|
ValueChanged="@((double newValue) => ChangeVolume(newValue))" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
<audio id="@_audioId" style="display: none;"></audio>
|
||||||
|
|
||||||
|
@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!;
|
||||||
|
|
||||||
|
/// <summary>Требовать ли авторизацию для воспроизведения (по умолчанию true).</summary>
|
||||||
|
[Parameter] public bool RequireAuth { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>Событие при завершении трека.</summary>
|
||||||
|
[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<IJSObjectReference>("import", "/js/AudioPlayer.js");
|
||||||
|
if (_audioElement == null)
|
||||||
|
_audioElement = await _audioModule.InvokeAsync<IJSObjectReference>("init", _audioId, DotNetObjectReference.Create(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> 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<double>("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<double>("getCurrentTime");
|
||||||
|
var duration = await _audioElement.InvokeAsync<double>("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 { }
|
||||||
|
}
|
||||||
|
}
|
||||||
50
PlaylistShared.Pwa/Components/TrackCoverWithPlay.razor
Normal file
50
PlaylistShared.Pwa/Components/TrackCoverWithPlay.razor
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
@using Microsoft.AspNetCore.Components.Web
|
||||||
|
|
||||||
|
<div class="track-cover-container"
|
||||||
|
@onmouseenter="HandleMouseEnter"
|
||||||
|
@onmouseleave="HandleMouseLeave"
|
||||||
|
style="position: relative; display: inline-block; cursor: pointer;">
|
||||||
|
|
||||||
|
<MudImage Src="@FormatCoverUrl(CoverUrl)" Height="@Height" Width="@Width" Class="rounded" Style="display: block;" />
|
||||||
|
|
||||||
|
@if (_isHovered || IsPlaying)
|
||||||
|
{
|
||||||
|
<div class="play-overlay"
|
||||||
|
style="position: absolute; top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
background: rgba(0,0,0,0.6);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 4px;">
|
||||||
|
<MudIconButton Icon="@(IsPlaying? Icons.Material.Filled.Pause : Icons.Material.Filled.PlayArrow)"
|
||||||
|
Color="Color.Inherit"
|
||||||
|
Size="Size.Large"
|
||||||
|
OnClick="OnPlayClick" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@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<string> 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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -143,7 +143,12 @@
|
|||||||
<MudTd>
|
<MudTd>
|
||||||
@if (!string.IsNullOrEmpty(context.CoverUri))
|
@if (!string.IsNullOrEmpty(context.CoverUri))
|
||||||
{
|
{
|
||||||
<MudImage Src="@FormatCoverUrl(context.CoverUri, "50x50")" Height="50" Width="50" Class="rounded" />
|
<TrackCoverWithPlay CoverUrl="@context.CoverUri"
|
||||||
|
TrackId="@context.Id"
|
||||||
|
Width="50"
|
||||||
|
Height="50"
|
||||||
|
IsPlaying="@(_currentTrackId == context.Id && _isPlaying)"
|
||||||
|
OnPlay="PlayTrack" />
|
||||||
}
|
}
|
||||||
</MudTd>
|
</MudTd>
|
||||||
<MudTd>
|
<MudTd>
|
||||||
@@ -165,15 +170,20 @@
|
|||||||
}
|
}
|
||||||
</RowTemplate>
|
</RowTemplate>
|
||||||
</MudTable>
|
</MudTable>
|
||||||
|
<AudioPlayer @ref="_audioPlayer" OnTrackEnded="OnTrackEnded" />
|
||||||
}
|
}
|
||||||
</MudCardContent>
|
</MudCardContent>
|
||||||
</MudCard>
|
</MudCard>
|
||||||
}
|
}
|
||||||
</MudContainer>
|
</MudContainer>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Parameter] public string Token { get; set; }
|
[Parameter] public string Token { get; set; }
|
||||||
|
|
||||||
|
private AudioPlayer? _audioPlayer;
|
||||||
|
private string? _currentTrackId { get; set; }
|
||||||
|
private bool _isPlaying = false;
|
||||||
|
|
||||||
private SharedPlaylistDto? _playlist;
|
private SharedPlaylistDto? _playlist;
|
||||||
private bool _loading = true;
|
private bool _loading = true;
|
||||||
private bool _isAuthenticated;
|
private bool _isAuthenticated;
|
||||||
@@ -402,4 +412,36 @@
|
|||||||
{
|
{
|
||||||
public int Index { get; set; }
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -24,4 +24,10 @@
|
|||||||
<ServiceWorker Include="wwwroot\\service-worker.js" PublishedContent="wwwroot\\service-worker.published.js" />
|
<ServiceWorker Include="wwwroot\\service-worker.js" PublishedContent="wwwroot\\service-worker.published.js" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Content Update="wwwroot\js\AudioPlayer.js">
|
||||||
|
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -25,14 +25,6 @@ internal class Program
|
|||||||
builder.Services.AddScoped<AuthenticationStateProvider>(sp => sp.GetRequiredService<AuthStateProvider>());
|
builder.Services.AddScoped<AuthenticationStateProvider>(sp => sp.GetRequiredService<AuthStateProvider>());
|
||||||
builder.Services.AddScoped<ApiClient>();
|
builder.Services.AddScoped<ApiClient>();
|
||||||
|
|
||||||
/*
|
|
||||||
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.AddAuthorizationCore();
|
||||||
builder.Services.AddCascadingAuthenticationState();
|
builder.Services.AddCascadingAuthenticationState();
|
||||||
|
|
||||||
|
|||||||
@@ -13,3 +13,4 @@
|
|||||||
@using MudBlazor
|
@using MudBlazor
|
||||||
@using Microsoft.AspNetCore.Authorization
|
@using Microsoft.AspNetCore.Authorization
|
||||||
@using PlaylistShared.Shared
|
@using PlaylistShared.Shared
|
||||||
|
@using PlaylistShared.Pwa.Components
|
||||||
@@ -108,3 +108,18 @@ code {
|
|||||||
.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder {
|
.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder {
|
||||||
text-align: start;
|
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;
|
||||||
|
}
|
||||||
54
PlaylistShared.Pwa/wwwroot/js/AudioPlayer.js
Normal file
54
PlaylistShared.Pwa/wwwroot/js/AudioPlayer.js
Normal file
@@ -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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user