Compare commits
7 Commits
b46e3a0715
...
0381ef74ab
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0381ef74ab | ||
|
|
acf02c85a7 | ||
|
|
e0fca7e55e | ||
|
|
65efb9ff76 | ||
|
|
0369f0af07 | ||
|
|
b012fe37cc | ||
|
|
3a42a17ce7 |
@@ -3,6 +3,8 @@ using Microsoft.AspNetCore.Identity;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using PlaylistShared.Api.Entities;
|
using PlaylistShared.Api.Entities;
|
||||||
using PlaylistShared.Api.Services;
|
using PlaylistShared.Api.Services;
|
||||||
|
using PlaylistShared.Shared;
|
||||||
|
using PlaylistShared.Shared.DTO;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
|
||||||
namespace PlaylistShared.Api.Controllers;
|
namespace PlaylistShared.Api.Controllers;
|
||||||
@@ -70,6 +72,24 @@ public class AudioController : ControllerBase
|
|||||||
return new EmptyResult();
|
return new EmptyResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("track-info/{trackId}")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
public async Task<ActionResult<ApiResponse<TrackInfoDto>>> GetTrackInfo(string trackId, [FromQuery] string? access_token = null, [FromQuery] string? shared_id = null)
|
||||||
|
{
|
||||||
|
var user = await GetUserFromToken(access_token);
|
||||||
|
if (user == null) user = await GetUserFromSharedPlaylistId(shared_id);
|
||||||
|
if (user == null) return Unauthorized();
|
||||||
|
|
||||||
|
var track = await _yandexService.GetYTrackAsync(user, trackId);
|
||||||
|
if (track == null) return NotFound();
|
||||||
|
|
||||||
|
return Ok(ApiResponse<TrackInfoDto>.Ok(new TrackInfoDto
|
||||||
|
{
|
||||||
|
Title = track.Title,
|
||||||
|
CoverUri = track.CoverUri,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<ApplicationUser?> GetUserFromToken(string? token)
|
private async Task<ApplicationUser?> GetUserFromToken(string? token)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(token)) return null;
|
if (string.IsNullOrEmpty(token)) return null;
|
||||||
|
|||||||
74
PlaylistShared.Api/Controllers/YandexSearchController.cs
Normal file
74
PlaylistShared.Api/Controllers/YandexSearchController.cs
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using PlaylistShared.Api.Entities;
|
||||||
|
using PlaylistShared.Api.Extensions;
|
||||||
|
using PlaylistShared.Api.Services;
|
||||||
|
using PlaylistShared.Shared;
|
||||||
|
using PlaylistShared.Shared.DTO;
|
||||||
|
|
||||||
|
namespace PlaylistShared.Api.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
public class YandexSearchController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly UserManager<ApplicationUser> _userManager;
|
||||||
|
private readonly YandexMusicService _yandexService;
|
||||||
|
private readonly SharedPlaylistService _sharedPlaylistService;
|
||||||
|
|
||||||
|
public YandexSearchController(UserManager<ApplicationUser> userManager, YandexMusicService yandexService, SharedPlaylistService sharedPlaylistService)
|
||||||
|
{
|
||||||
|
_userManager = userManager;
|
||||||
|
_yandexService = yandexService;
|
||||||
|
_sharedPlaylistService = sharedPlaylistService;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("tracks")]
|
||||||
|
public async Task<ActionResult<ApiResponse<List<YandexTrackSearchResult>>>> SearchTracks(
|
||||||
|
[FromQuery] string query,
|
||||||
|
[FromQuery] int limit = 20,
|
||||||
|
[FromQuery] string? shared_id = null)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(query))
|
||||||
|
return BadRequest(ApiResponse<List<YandexTrackSearchResult>>.Fail(new ErrorResponse
|
||||||
|
{
|
||||||
|
StatusCode = 400,
|
||||||
|
Message = "Поисковый запрос не может быть пустым."
|
||||||
|
}));
|
||||||
|
|
||||||
|
ApplicationUser? user = null;
|
||||||
|
var userId = User.GetUserIdOrNull();
|
||||||
|
if (userId.HasValue)
|
||||||
|
user = await _userManager.FindByIdAsync(userId.Value.ToString());
|
||||||
|
|
||||||
|
// Если нет пользователя или у него нет токена, пробуем через shared_id
|
||||||
|
if (user == null || string.IsNullOrEmpty(_yandexService.DecryptToken(user.YandexAccessToken)))
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(shared_id))
|
||||||
|
return Unauthorized("Не установлен яндекс токен.");
|
||||||
|
|
||||||
|
var playlist = await _sharedPlaylistService.GetEntityByTokenAsync(shared_id);
|
||||||
|
if (playlist == null) return NotFound("Не найден плейлист.");
|
||||||
|
|
||||||
|
if (!await _sharedPlaylistService.CanAddTrackAsync(playlist, userId))
|
||||||
|
return StatusCode(403, "Нет доступа для добавления трека.");
|
||||||
|
|
||||||
|
var owner = await _userManager.FindByIdAsync(playlist.CreatorUserId.ToString());
|
||||||
|
if (owner == null) return StatusCode(500, "Не удалось найти владельца плейлиста.");
|
||||||
|
user = owner;
|
||||||
|
}
|
||||||
|
|
||||||
|
var decryptedToken = _yandexService.DecryptToken(user.YandexAccessToken);
|
||||||
|
if (string.IsNullOrEmpty(decryptedToken))
|
||||||
|
return BadRequest(ApiResponse<List<YandexTrackSearchResult>>.Fail(new ErrorResponse
|
||||||
|
{
|
||||||
|
StatusCode = 400,
|
||||||
|
Message = "Токен Яндекс.Музыки не установлен или недействителен."
|
||||||
|
}));
|
||||||
|
|
||||||
|
var results = await _yandexService.SearchTracksAsync(user, query, limit);
|
||||||
|
return Ok(ApiResponse<List<YandexTrackSearchResult>>.Ok(results));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
using Microsoft.AspNetCore.DataProtection;
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
using PlaylistShared.Api.Entities;
|
using PlaylistShared.Api.Entities;
|
||||||
|
using PlaylistShared.Shared.DTO;
|
||||||
using YandexMusic;
|
using YandexMusic;
|
||||||
using YandexMusic.API.Extensions.API;
|
using YandexMusic.API.Extensions.API;
|
||||||
using YandexMusic.API.Models.Playlist;
|
using YandexMusic.API.Models.Playlist;
|
||||||
|
using YandexMusic.API.Models.Track;
|
||||||
|
|
||||||
namespace PlaylistShared.Api.Services;
|
namespace PlaylistShared.Api.Services;
|
||||||
|
|
||||||
@@ -73,12 +75,18 @@ public class YandexMusicService
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string?> GetTrackFileUrlAsync(ApplicationUser user, string trackId)
|
public async Task<string?> GetTrackFileUrlAsync(ApplicationUser user, string trackId)
|
||||||
|
{
|
||||||
|
var track = await GetYTrackAsync(user, trackId);
|
||||||
|
if (track == null) return null;
|
||||||
|
return await track.GetLinkAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<YTrack?> GetYTrackAsync(ApplicationUser user, string trackId)
|
||||||
{
|
{
|
||||||
using var client = await CreateClientAsync(user);
|
using var client = await CreateClientAsync(user);
|
||||||
if (client == null) return null;
|
if (client == null) return null;
|
||||||
var track = await client.GetTrackAsync(trackId);
|
var track = await client.GetTrackAsync(trackId);
|
||||||
if (track == null) return null;
|
return track;
|
||||||
return await track.GetLinkAsync();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public string EncryptToken(string token) => _dataProtector.Protect(token);
|
public string EncryptToken(string token) => _dataProtector.Protect(token);
|
||||||
@@ -94,4 +102,22 @@ public class YandexMusicService
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<List<YandexTrackSearchResult>> SearchTracksAsync(ApplicationUser user, string query, int limit = 20)
|
||||||
|
{
|
||||||
|
var client = await CreateClientAsync(user);
|
||||||
|
if (client == null) return new List<YandexTrackSearchResult>();
|
||||||
|
|
||||||
|
var searchResult = await client.SearchAsync(query, YandexMusic.API.Models.Common.YSearchType.Track, page: 0, pageSize: limit);
|
||||||
|
if (searchResult?.Tracks?.Results == null) return new List<YandexTrackSearchResult>();
|
||||||
|
|
||||||
|
return searchResult.Tracks.Results.Select(t => new YandexTrackSearchResult
|
||||||
|
{
|
||||||
|
TrackId = t.Id,
|
||||||
|
Title = t.Title,
|
||||||
|
Artists = t.Artists?.Select(a => a.Name).ToList() ?? new List<string>(),
|
||||||
|
CoverUri = t.CoverUri ?? string.Empty,
|
||||||
|
DurationMs = t.DurationMs,
|
||||||
|
}).ToList();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
85
PlaylistShared.Pwa/Components/Common/ShareButton.razor
Normal file
85
PlaylistShared.Pwa/Components/Common/ShareButton.razor
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
@inject IJSRuntime JS
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.Share"
|
||||||
|
Color="Color.Default"
|
||||||
|
OnClick="@TogglePopover"
|
||||||
|
Title="Поделиться"
|
||||||
|
Size="Size.Medium" />
|
||||||
|
|
||||||
|
<MudPopover Open="@_popoverOpen"
|
||||||
|
AnchorOrigin="Origin.BottomCenter"
|
||||||
|
TransformOrigin="Origin.TopCenter"
|
||||||
|
RelativeWidth="DropdownWidth.Adaptive"
|
||||||
|
Paper="true">
|
||||||
|
<MudPaper Class="pa-4">
|
||||||
|
<MudText Typo="Typo.body2" Class="mb-2">Ссылка для приглашения:</MudText>
|
||||||
|
<div style="display: flex; gap: 8px; align-items: center;">
|
||||||
|
<MudTextField @bind-Value="_shareUrl"
|
||||||
|
ReadOnly="true"
|
||||||
|
Variant="Variant.Outlined"
|
||||||
|
FullWidth="true" />
|
||||||
|
<MudIconButton Variant="Variant.Filled"
|
||||||
|
Color="Color.Primary"
|
||||||
|
Size="Size.Medium"
|
||||||
|
OnClick="CopyLink"
|
||||||
|
Icon="@Icons.Material.Filled.ContentCopy">
|
||||||
|
</MudIconButton>
|
||||||
|
</div>
|
||||||
|
</MudPaper>
|
||||||
|
</MudPopover>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private bool _popoverOpen;
|
||||||
|
private string _shareUrl = "";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ссылка для копирования. Если не указана, используется текущий URL страницы.
|
||||||
|
/// </summary>
|
||||||
|
[Parameter] public string ShareUrl { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(ShareUrl))
|
||||||
|
ShareUrl = Navigation.Uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnParametersSet()
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(ShareUrl))
|
||||||
|
_shareUrl = ShareUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task TogglePopover()
|
||||||
|
{
|
||||||
|
if (_popoverOpen)
|
||||||
|
{
|
||||||
|
_popoverOpen = false;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(ShareUrl))
|
||||||
|
{
|
||||||
|
Snackbar.Add("Ссылка недоступна", Severity.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_shareUrl = ShareUrl;
|
||||||
|
_popoverOpen = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CopyLink()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await JS.InvokeVoidAsync("navigator.clipboard.writeText", _shareUrl);
|
||||||
|
Snackbar.Add("Ссылка скопирована в буфер обмена", Severity.Success);
|
||||||
|
_popoverOpen = false;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Не удалось скопировать ссылку: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
@using Microsoft.AspNetCore.Components.Web
|
@using Microsoft.AspNetCore.Components.Web
|
||||||
|
@inject IAudioPlayerService AudioPlayerService
|
||||||
|
|
||||||
<div class="track-cover-container"
|
<div class="track-cover-container"
|
||||||
@onmouseenter="HandleMouseEnter"
|
@onmouseenter="HandleMouseEnter"
|
||||||
@onmouseleave="HandleMouseLeave"
|
@onmouseleave="HandleMouseLeave"
|
||||||
style="position: relative; display: inline-block; cursor: pointer;">
|
style="position: relative; display: inline-block; cursor: pointer;">
|
||||||
|
|
||||||
<MudImage Src="@FormatCoverUrl(CoverUrl)" Height="@Height" Width="@Width" Class="rounded" Style="display: block;" />
|
<MudImage Src="@CoverUrl.FormatCoverUrl(Width, Height)" Height="@Height" Width="@Width" Class="rounded" Style="display: block;" />
|
||||||
|
|
||||||
@if (_isHovered || IsPlaying)
|
@if (_isHovered || IsCurrentTrackPlaying)
|
||||||
{
|
{
|
||||||
<div class="play-overlay"
|
<div class="play-overlay"
|
||||||
style="position: absolute; top: 0; left: 0; right: 0; bottom: 0;
|
style="position: absolute; top: 0; left: 0; right: 0; bottom: 0;
|
||||||
@@ -16,7 +17,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
border-radius: 4px;">
|
border-radius: 4px;">
|
||||||
<MudIconButton Icon="@(IsPlaying? Icons.Material.Filled.Pause : Icons.Material.Filled.PlayArrow)"
|
<MudIconButton Icon="@(IsCurrentTrackPlaying? Icons.Material.Filled.Pause : Icons.Material.Filled.PlayArrow)"
|
||||||
Color="Color.Inherit"
|
Color="Color.Inherit"
|
||||||
Size="Size.Large"
|
Size="Size.Large"
|
||||||
OnClick="OnPlayClick" />
|
OnClick="OnPlayClick" />
|
||||||
@@ -27,24 +28,38 @@
|
|||||||
@code {
|
@code {
|
||||||
[Parameter] public string CoverUrl { get; set; } = string.Empty;
|
[Parameter] public string CoverUrl { get; set; } = string.Empty;
|
||||||
[Parameter] public string TrackId { 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 Height { get; set; } = 50;
|
||||||
[Parameter] public int Width { get; set; } = 50;
|
[Parameter] public int Width { get; set; } = 50;
|
||||||
|
[Parameter] public string SharedPlaylistId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
private bool IsCurrentTrackPlaying => AudioPlayerService.IsPlaying && AudioPlayerService.CurrentTrackId == TrackId;
|
||||||
|
|
||||||
private bool _isHovered;
|
private bool _isHovered;
|
||||||
|
|
||||||
private void HandleMouseEnter() => _isHovered = true;
|
private void HandleMouseEnter() => _isHovered = true;
|
||||||
private void HandleMouseLeave() => _isHovered = false;
|
private void HandleMouseLeave() => _isHovered = false;
|
||||||
|
|
||||||
private async Task OnPlayClick()
|
protected override void OnInitialized()
|
||||||
{
|
{
|
||||||
await OnPlay.InvokeAsync(TrackId);
|
AudioPlayerService.OnStateChanged += OnPlayerStateChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
private string FormatCoverUrl(string? url)
|
private async Task OnPlayClick()
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(url)) return "";
|
var sharedPlaylistId = string.IsNullOrWhiteSpace(SharedPlaylistId) ? null : SharedPlaylistId;
|
||||||
return "https://" + url.Replace("%%", $"{Width}x{Height}");
|
|
||||||
|
if (IsCurrentTrackPlaying)
|
||||||
|
{
|
||||||
|
await AudioPlayerService.PauseAsync();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await AudioPlayerService.LoadAndPlayAsync(TrackId, sharedPlaylistId: SharedPlaylistId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPlayerStateChanged()
|
||||||
|
{
|
||||||
|
InvokeAsync(StateHasChanged);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,22 @@
|
|||||||
@using Microsoft.JSInterop
|
@using Microsoft.JSInterop
|
||||||
@using Microsoft.AspNetCore.Components.Authorization
|
@inject IAudioPlayerService AudioPlayerService
|
||||||
@namespace PlaylistShared.Pwa.Components
|
@inject IJSRuntime JS
|
||||||
|
@inject TokenStorage TokenStorage
|
||||||
|
@inject PlayerStorage PlayerStorage
|
||||||
|
@inject AuthenticationStateProvider AuthProvider
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
@inject HttpClient Http
|
@inject HttpClient Http
|
||||||
|
|
||||||
<MudPaper Class="pa-4" Elevation="0" Style="background-color: rgba(0,0,0,0.05); border-radius: 8px;">
|
<MudPaper Class="pa-4" Elevation="0" Width="100%" 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; align-items: center; gap: 12px; flex-wrap: wrap;">
|
||||||
|
<div style="display: flex; align-items: center; gap: 12px;">
|
||||||
|
@if (!string.IsNullOrEmpty(_currentTrackCoverUrl))
|
||||||
|
{
|
||||||
|
<MudImage Src="@_currentTrackCoverUrl" Height="40" Width="40" Class="rounded" />
|
||||||
|
}
|
||||||
|
<MudText Typo="Typo.body1" Style="font-weight: 500;">@_currentTrackTitle</MudText>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style="display: flex; gap: 8px;">
|
<div style="display: flex; gap: 8px;">
|
||||||
<MudIconButton Icon="@(_isPlaying? Icons.Material.Filled.Pause : Icons.Material.Filled.PlayArrow)"
|
<MudIconButton Icon="@(_isPlaying? Icons.Material.Filled.Pause : Icons.Material.Filled.PlayArrow)"
|
||||||
Size="Size.Medium"
|
Size="Size.Medium"
|
||||||
@@ -46,73 +58,57 @@
|
|||||||
<audio id="@_audioId" style="display: none;"></audio>
|
<audio id="@_audioId" style="display: none;"></audio>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
|
private const double _defaultVolume = 50;
|
||||||
|
|
||||||
private string _audioId = $"audio_{Guid.NewGuid():N}";
|
private string _audioId = $"audio_{Guid.NewGuid():N}";
|
||||||
private IJSObjectReference? _audioModule;
|
private IJSObjectReference? _audioModule;
|
||||||
private IJSObjectReference? _audioElement;
|
private IJSObjectReference? _audioElement;
|
||||||
private double _currentProgress;
|
private double _currentProgress;
|
||||||
private double _currentVolume = 70;
|
private double _currentVolume = _defaultVolume;
|
||||||
private string _currentTime = "0:00";
|
private string _currentTime = "0:00";
|
||||||
private string _totalTime = "0:00";
|
private string _totalTime = "0:00";
|
||||||
private bool _isPlaying;
|
private bool _isPlaying;
|
||||||
private Timer? _progressTimer;
|
private Timer? _progressTimer;
|
||||||
private bool _isMuted;
|
private bool _isMuted;
|
||||||
|
private string? _currentAccessToken;
|
||||||
|
private string? _currentSharedPlaylistId;
|
||||||
|
private string? _currentTrackCoverUrl;
|
||||||
|
private string? _currentTrackTitle;
|
||||||
|
|
||||||
[Inject] protected IJSRuntime JS { get; set; } = null!;
|
protected override async Task OnInitializedAsync()
|
||||||
[Inject] private TokenStorage TokenStorage { get; set; } = null!;
|
{
|
||||||
[Inject] private PlayerStorage PlayerStorage { get; set; } = null!;
|
AudioPlayerService.OnLoadAndPlayRequested += OnLoadAndPlay;
|
||||||
[Inject] private AuthenticationStateProvider AuthProvider { get; set; } = null!;
|
AudioPlayerService.OnPlayRequested += OnPlay;
|
||||||
[Inject] private ISnackbar Snackbar { get; set; } = null!;
|
AudioPlayerService.OnPauseRequested += OnPause;
|
||||||
|
AudioPlayerService.OnStopRequested += OnStop;
|
||||||
|
AudioPlayerService.OnSeekRequested += OnSeek;
|
||||||
|
AudioPlayerService.OnVolumeChangeRequested += OnVolumeChange;
|
||||||
|
AudioPlayerService.OnStateChanged += OnStateChanged;
|
||||||
|
|
||||||
/// <summary>Требовать ли авторизацию для воспроизведения (по умолчанию true).</summary>
|
await LoadSavedVolume();
|
||||||
[Parameter] public bool RequireAuth { get; set; } = true;
|
await AudioPlayerService.SetVolumeAsync(_currentVolume); // синхронизация
|
||||||
|
}
|
||||||
/// <summary>ID расшаренного плейлиста.</summary>
|
|
||||||
[Parameter] public string SharedPlaylistId { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>Событие при завершении трека.</summary>
|
|
||||||
[Parameter] public EventCallback OnTrackEnded { get; set; }
|
|
||||||
|
|
||||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
{
|
{
|
||||||
if (firstRender)
|
if (firstRender)
|
||||||
{
|
{
|
||||||
await EnsureAudioModuleAsync();
|
await EnsureAudioModuleAsync();
|
||||||
await ChangeVolume(await PlayerStorage.GetVolumeAsync());
|
|
||||||
StateHasChanged();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[JSInvokable]
|
private void OnStateChanged()
|
||||||
public async Task OnAudioEnded()
|
|
||||||
{
|
{
|
||||||
_isPlaying = false;
|
_currentTrackTitle = AudioPlayerService.CurrentTrackTitle;
|
||||||
_currentProgress = 0;
|
_currentTrackCoverUrl = AudioPlayerService.CurrentTrackCoverUrl?.FormatCoverUrl(40, 40);
|
||||||
StopProgressTimer();
|
InvokeAsync(StateHasChanged);
|
||||||
if (OnTrackEnded.HasDelegate)
|
|
||||||
await OnTrackEnded.InvokeAsync();
|
|
||||||
StateHasChanged();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[JSInvokable]
|
private async Task LoadSavedVolume()
|
||||||
public async Task OnTimeUpdate(double currentTime, double duration)
|
|
||||||
{
|
{
|
||||||
// Защита от некорректных значений
|
var savedVolume = await PlayerStorage.GetVolumeAsync() ?? _defaultVolume;
|
||||||
if (double.IsNaN(currentTime) || double.IsNaN(duration) || double.IsInfinity(currentTime) || double.IsInfinity(duration))
|
_currentVolume = savedVolume;
|
||||||
return;
|
await AudioPlayerService.SetVolumeAsync(savedVolume);
|
||||||
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()
|
private async Task EnsureAudioModuleAsync()
|
||||||
@@ -123,14 +119,42 @@
|
|||||||
_audioElement = await _audioModule.InvokeAsync<IJSObjectReference>("init", _audioId, DotNetObjectReference.Create(this));
|
_audioElement = await _audioModule.InvokeAsync<IJSObjectReference>("init", _audioId, DotNetObjectReference.Create(this));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[JSInvokable]
|
||||||
|
public async Task OnAudioEnded()
|
||||||
|
{
|
||||||
|
AudioPlayerService.NotifyTrackEnded();
|
||||||
|
_isPlaying = false;
|
||||||
|
_currentProgress = 0;
|
||||||
|
StopProgressTimer();
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
[JSInvokable]
|
||||||
|
public async Task OnTimeUpdate(double currentTime, double duration)
|
||||||
|
{
|
||||||
|
if (double.IsNaN(currentTime) || double.IsNaN(duration) || duration <= 0) return;
|
||||||
|
var progress = (currentTime / duration) * 100;
|
||||||
|
var currentTimeStr = FormatTime(currentTime);
|
||||||
|
var totalTimeStr = FormatTime(duration);
|
||||||
|
AudioPlayerService.UpdateProgress(progress, currentTimeStr, totalTimeStr);
|
||||||
|
_currentProgress = progress;
|
||||||
|
_currentTime = currentTimeStr;
|
||||||
|
_totalTime = totalTimeStr;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
[JSInvokable]
|
||||||
|
public async Task OnDurationReady(double duration)
|
||||||
|
{
|
||||||
|
if (duration <= 0) return;
|
||||||
|
_totalTime = FormatTime(duration);
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<bool> CheckAuthAsync()
|
private async Task<bool> CheckAuthAsync()
|
||||||
{
|
{
|
||||||
if (!RequireAuth) return true;
|
|
||||||
|
|
||||||
var authState = await AuthProvider.GetAuthenticationStateAsync();
|
var authState = await AuthProvider.GetAuthenticationStateAsync();
|
||||||
var isAuthenticated = authState.User.Identity?.IsAuthenticated == true;
|
if (!authState.User.Identity?.IsAuthenticated == true)
|
||||||
|
|
||||||
if (!isAuthenticated)
|
|
||||||
{
|
{
|
||||||
Snackbar.Add("Воспроизведение доступно только авторизованным пользователям", Severity.Warning);
|
Snackbar.Add("Воспроизведение доступно только авторизованным пользователям", Severity.Warning);
|
||||||
return false;
|
return false;
|
||||||
@@ -138,32 +162,30 @@
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task LoadAndPlayAsync(string trackId)
|
private async Task OnLoadAndPlay(string trackId, string? accessToken, string? sharedPlaylistId)
|
||||||
{
|
{
|
||||||
if (!await CheckAuthAsync()) return;
|
if (!await CheckAuthAsync()) return;
|
||||||
|
|
||||||
var tokens = await TokenStorage.GetTokensAsync();
|
var tokens = await TokenStorage.GetTokensAsync();
|
||||||
var accessToken = tokens.token;
|
_currentAccessToken = accessToken ?? tokens.token;
|
||||||
|
_currentSharedPlaylistId = sharedPlaylistId;
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(accessToken) && string.IsNullOrWhiteSpace(SharedPlaylistId))
|
if (string.IsNullOrWhiteSpace(_currentAccessToken) && string.IsNullOrWhiteSpace(_currentSharedPlaylistId))
|
||||||
{
|
{
|
||||||
Snackbar.Add("Токен авторизации не найден. Пожалуйста, войдите заново.", Severity.Error);
|
Snackbar.Add("Токен авторизации не найден. Пожалуйста, войдите заново.", Severity.Error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var streamUrl = new Uri(Http.BaseAddress!, $"/api/audio/track/{trackId}").ToString();
|
var streamUrl = new Uri(Http.BaseAddress!, $"/api/audio/track/{trackId}").ToString();
|
||||||
|
|
||||||
await EnsureAudioModuleAsync();
|
await EnsureAudioModuleAsync();
|
||||||
await _audioElement!.InvokeVoidAsync("loadAndPlay", streamUrl, accessToken, SharedPlaylistId);
|
await _audioElement!.InvokeVoidAsync("loadAndPlay", streamUrl, _currentAccessToken, _currentSharedPlaylistId);
|
||||||
_isPlaying = true;
|
_isPlaying = true;
|
||||||
StartProgressTimer();
|
StartProgressTimer();
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task PlayAsync()
|
private async Task OnPlay()
|
||||||
{
|
{
|
||||||
if (!await CheckAuthAsync()) return;
|
|
||||||
|
|
||||||
if (_audioElement == null) return;
|
if (_audioElement == null) return;
|
||||||
await _audioElement.InvokeVoidAsync("play");
|
await _audioElement.InvokeVoidAsync("play");
|
||||||
_isPlaying = true;
|
_isPlaying = true;
|
||||||
@@ -171,7 +193,7 @@
|
|||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task PauseAsync()
|
private async Task OnPause()
|
||||||
{
|
{
|
||||||
if (_audioElement == null) return;
|
if (_audioElement == null) return;
|
||||||
await _audioElement.InvokeVoidAsync("pause");
|
await _audioElement.InvokeVoidAsync("pause");
|
||||||
@@ -180,7 +202,7 @@
|
|||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task StopAsync()
|
private async Task OnStop()
|
||||||
{
|
{
|
||||||
if (_audioElement == null) return;
|
if (_audioElement == null) return;
|
||||||
await _audioElement.InvokeVoidAsync("stop");
|
await _audioElement.InvokeVoidAsync("stop");
|
||||||
@@ -190,20 +212,7 @@
|
|||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task TogglePlayPause()
|
private async Task OnSeek(double percent)
|
||||||
{
|
|
||||||
if (_isPlaying)
|
|
||||||
await PauseAsync();
|
|
||||||
else
|
|
||||||
await PlayAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task Stop()
|
|
||||||
{
|
|
||||||
await StopAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task SeekTo(double value)
|
|
||||||
{
|
{
|
||||||
if (_audioElement == null) return;
|
if (_audioElement == null) return;
|
||||||
try
|
try
|
||||||
@@ -211,71 +220,87 @@
|
|||||||
var duration = await _audioElement.InvokeAsync<double>("getDuration");
|
var duration = await _audioElement.InvokeAsync<double>("getDuration");
|
||||||
if (duration > 0 && !double.IsNaN(duration))
|
if (duration > 0 && !double.IsNaN(duration))
|
||||||
{
|
{
|
||||||
var newTime = (value / 100) * duration;
|
var newTime = (percent / 100) * duration;
|
||||||
await _audioElement.InvokeVoidAsync("setCurrentTime", newTime);
|
await _audioElement.InvokeVoidAsync("setCurrentTime", newTime);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"SeekTo error: {ex.Message}");
|
Console.WriteLine($"Seek error: {ex.Message}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ChangeVolume(double value)
|
private async Task OnVolumeChange(double volume)
|
||||||
{
|
{
|
||||||
if (_audioElement == null) return;
|
if (_audioElement == null) return;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var volume = value / 100;
|
await _audioElement.InvokeVoidAsync("setVolume", volume / 100);
|
||||||
await _audioElement.InvokeVoidAsync("setVolume", volume);
|
|
||||||
_isMuted = false;
|
_isMuted = false;
|
||||||
_currentVolume = value;
|
_currentVolume = volume;
|
||||||
await PlayerStorage.SetVolumeAsync(value);
|
await PlayerStorage.SetVolumeAsync(volume);
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"ChangeVolume error: {ex.Message}");
|
Console.WriteLine($"Volume change error: {ex.Message}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task TogglePlayPause()
|
||||||
|
{
|
||||||
|
if (_isPlaying)
|
||||||
|
await AudioPlayerService.PauseAsync();
|
||||||
|
else
|
||||||
|
await AudioPlayerService.PlayAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task Stop()
|
||||||
|
{
|
||||||
|
await AudioPlayerService.StopAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SeekTo(double value)
|
||||||
|
{
|
||||||
|
await AudioPlayerService.SeekToAsync(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ChangeVolume(double value)
|
||||||
|
{
|
||||||
|
await AudioPlayerService.SetVolumeAsync(value);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task ToggleMute()
|
private async Task ToggleMute()
|
||||||
{
|
{
|
||||||
if (_audioElement == null) return;
|
|
||||||
_isMuted = !_isMuted;
|
_isMuted = !_isMuted;
|
||||||
var newVolume = _isMuted ? 0 : (_currentVolume / 100);
|
var newVolume = _isMuted ? 0 : _currentVolume;
|
||||||
await _audioElement.InvokeVoidAsync("setVolume", newVolume);
|
await AudioPlayerService.SetVolumeAsync(newVolume);
|
||||||
StateHasChanged();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void StartProgressTimer()
|
private void StartProgressTimer()
|
||||||
{
|
{
|
||||||
StopProgressTimer();
|
StopProgressTimer();
|
||||||
_progressTimer = new Timer(async _ =>
|
_progressTimer = new Timer(async _ => await UpdateProgress(), null, 0, 500);
|
||||||
{
|
|
||||||
await UpdateProgress();
|
|
||||||
}, null, 0, 500);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void StopProgressTimer() => _progressTimer?.Dispose();
|
private void StopProgressTimer() => _progressTimer?.Dispose();
|
||||||
|
|
||||||
private async Task UpdateProgress()
|
private async Task UpdateProgress()
|
||||||
{
|
{
|
||||||
if (_audioElement == null)
|
if (_audioElement == null) return;
|
||||||
{
|
|
||||||
Console.WriteLine("UpdateProgress: _audioElement is null");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var current = await _audioElement.InvokeAsync<double>("getCurrentTime");
|
var current = await _audioElement.InvokeAsync<double>("getCurrentTime");
|
||||||
var duration = await _audioElement.InvokeAsync<double>("getDuration");
|
var duration = await _audioElement.InvokeAsync<double>("getDuration");
|
||||||
|
|
||||||
if (duration > 0 && !double.IsNaN(duration) && !double.IsNaN(current))
|
if (duration > 0 && !double.IsNaN(duration) && !double.IsNaN(current))
|
||||||
{
|
{
|
||||||
_currentProgress = (current / duration) * 100;
|
var progress = (current / duration) * 100;
|
||||||
_currentTime = FormatTime(current);
|
var currentTime = FormatTime(current);
|
||||||
_totalTime = FormatTime(duration);
|
var totalTime = FormatTime(duration);
|
||||||
|
AudioPlayerService.UpdateProgress(progress, currentTime, totalTime);
|
||||||
|
_currentProgress = progress;
|
||||||
|
_currentTime = currentTime;
|
||||||
|
_totalTime = totalTime;
|
||||||
await InvokeAsync(StateHasChanged);
|
await InvokeAsync(StateHasChanged);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
@using PlaylistShared.Pwa.Components.Common
|
||||||
|
@using PlaylistShared.Shared.DTO
|
||||||
|
@inject HttpClient Http
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
|
||||||
|
<MudPaper Class="pa-4" Elevation="0" Style="background-color: rgba(0,0,0,0.05); border-radius: 8px;">
|
||||||
|
<MudText Typo="Typo.h6" GutterBottom>Поиск трека</MudText>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 8px; margin-bottom: 16px;">
|
||||||
|
<MudTextField @bind-Value="_searchQuery"
|
||||||
|
Label="Название трека или исполнитель"
|
||||||
|
Variant="Variant.Outlined"
|
||||||
|
FullWidth="true"
|
||||||
|
OnKeyDown="@(async (e) => { if (e.Key == "Enter") await SearchTracks(); })"
|
||||||
|
Placeholder="Например: Bohemian Rhapsody" />
|
||||||
|
<MudButton Variant="Variant.Filled"
|
||||||
|
Color="Color.Primary"
|
||||||
|
OnClick="SearchTracks"
|
||||||
|
Disabled="_isSearching"
|
||||||
|
StartIcon="@Icons.Material.Filled.Search">
|
||||||
|
Искать
|
||||||
|
</MudButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (_isSearching)
|
||||||
|
{
|
||||||
|
<div style="text-align: center; padding: 20px;">
|
||||||
|
<MudProgressCircular Indeterminate />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (_searchResults.Any())
|
||||||
|
{
|
||||||
|
<div style="max-height: 400px; overflow-y: auto;">
|
||||||
|
@foreach (var track in _searchResults)
|
||||||
|
{
|
||||||
|
<div style="display: flex; align-items: center; gap: 12px; padding: 8px; border-bottom: 1px solid rgba(0,0,0,0.1);">
|
||||||
|
<div style="width: 40px; height: 40px; flex-shrink: 0;">
|
||||||
|
<TrackCoverWithPlay CoverUrl="@track.CoverUri"
|
||||||
|
TrackId="@track.TrackId"
|
||||||
|
Width="40" Height="40"/>
|
||||||
|
</div>
|
||||||
|
<div style="flex: 1; min-width: 0;">
|
||||||
|
<MudText Typo="Typo.body1" Style="font-weight: 500;">@track.Title</MudText>
|
||||||
|
<MudText Typo="Typo.body2" Color="Color.Secondary">@string.Join(", ", track.Artists)</MudText>
|
||||||
|
</div>
|
||||||
|
<div style="flex-shrink: 0;">
|
||||||
|
<MudText Typo="Typo.body2">@FormatDuration(track.DurationMs)</MudText>
|
||||||
|
</div>
|
||||||
|
<div style="flex-shrink: 0;">
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.AddCircle"
|
||||||
|
Color="Color.Primary"
|
||||||
|
OnClick="() => AddTrack(track)"
|
||||||
|
Disabled="_addingTrackIds.Contains(track.TrackId)"
|
||||||
|
Title="Добавить в плейлист" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (!string.IsNullOrEmpty(_searchQuery) && !_isSearching)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Info">Ничего не найдено. Попробуйте изменить запрос.</MudAlert>
|
||||||
|
}
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public EventCallback<string> OnAddTrack { get; set; }
|
||||||
|
[Parameter] public string ShareToken { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
private string _searchQuery = "";
|
||||||
|
private List<YandexTrackSearchResult> _searchResults = new();
|
||||||
|
private bool _isSearching;
|
||||||
|
private HashSet<string> _addingTrackIds = new();
|
||||||
|
|
||||||
|
private async Task SearchTracks()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_searchQuery)) return;
|
||||||
|
|
||||||
|
_isSearching = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var url = $"/api/yandexsearch/tracks?query={Uri.EscapeDataString(_searchQuery)}&limit=20";
|
||||||
|
if (!string.IsNullOrEmpty(ShareToken))
|
||||||
|
url += $"&shared_id={Uri.EscapeDataString(ShareToken)}";
|
||||||
|
|
||||||
|
var response = await Http.GetFromJsonAsync<ApiResponse<List<YandexTrackSearchResult>>>(url);
|
||||||
|
if (response?.Success == true)
|
||||||
|
_searchResults = response.Data ?? new();
|
||||||
|
else
|
||||||
|
Snackbar.Add(response?.Error?.Message ?? "Ошибка поиска", Severity.Error);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Ошибка: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_isSearching = false;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AddTrack(YandexTrackSearchResult track)
|
||||||
|
{
|
||||||
|
if (_addingTrackIds.Contains(track.TrackId)) return;
|
||||||
|
_addingTrackIds.Add(track.TrackId);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await OnAddTrack.InvokeAsync(track.TrackId);
|
||||||
|
Snackbar.Add($"Трек \"{track.Title}\" добавлен", Severity.Success);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Ошибка добавления: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_addingTrackIds.Remove(track.TrackId);
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string FormatDuration(long ms)
|
||||||
|
{
|
||||||
|
var seconds = ms / 1000;
|
||||||
|
var mins = seconds / 60;
|
||||||
|
var secs = seconds % 60;
|
||||||
|
return $"{mins}:{secs:D2}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
@using PlaylistShared.Shared.Shared
|
||||||
|
@inject HttpClient Http
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
|
||||||
|
<MudPaper Class="mb-4" Elevation="0" Style="background-color: rgba(0,0,0,0.05); border-radius: 8px;">
|
||||||
|
<MudTabs @bind-ActivePanelIndex="_activeTabIndex" Elevation="0" Style="border-bottom: 1px solid rgba(0,0,0,0.1);">
|
||||||
|
<MudTabPanel Text="По ссылке" Style="padding: 16px;">
|
||||||
|
<MudGrid>
|
||||||
|
<MudItem xs="12" sm="10">
|
||||||
|
<MudTextField @bind-Value="_trackLink" Label="Ссылка на трек Яндекс.Музыки"
|
||||||
|
Variant="Variant.Outlined" FullWidth="true"
|
||||||
|
Placeholder="https://music.yandex.ru/album/2488464/track/21696942" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" sm="2">
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="AddTrackByLink"
|
||||||
|
Disabled="_addingTrack" FullWidth="true" Style="height: 100%;">
|
||||||
|
@if (_addingTrack)
|
||||||
|
{
|
||||||
|
<MudProgressCircular Size="Size.Small" Indeterminate />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudText>Добавить</MudText>
|
||||||
|
}
|
||||||
|
</MudButton>
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
<MudText Typo="Typo.body2" Class="mt-2" Color="Color.Secondary">
|
||||||
|
Поддерживаются ссылки вида: https://music.yandex.ru/album/12345/track/67890
|
||||||
|
</MudText>
|
||||||
|
</MudTabPanel>
|
||||||
|
<MudTabPanel Text="Поиск" Style="padding: 16px;">
|
||||||
|
<AddTrackBySearch OnAddTrack="AddTrackById"
|
||||||
|
ShareToken="@ShareToken" />
|
||||||
|
</MudTabPanel>
|
||||||
|
</MudTabs>
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private int _activeTabIndex = 0;
|
||||||
|
private string _trackLink = "";
|
||||||
|
private bool _addingTrack = false;
|
||||||
|
|
||||||
|
[Parameter] public string ShareToken { get; set; } = string.Empty;
|
||||||
|
[Parameter] public EventCallback OnTrackAdded { get; set; }
|
||||||
|
|
||||||
|
private async Task AddTrackByLink()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_trackLink))
|
||||||
|
{
|
||||||
|
Snackbar.Add("Введите ссылку на трек", Severity.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var trackId = ExtractTrackIdFromLink(_trackLink);
|
||||||
|
if (string.IsNullOrEmpty(trackId))
|
||||||
|
{
|
||||||
|
Snackbar.Add("Неверный формат ссылки", Severity.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await AddTrackById(trackId);
|
||||||
|
if (!_addingTrack) // если не было ошибки
|
||||||
|
_trackLink = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AddTrackById(string trackId)
|
||||||
|
{
|
||||||
|
_addingTrack = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var request = new AddTracksRequest { TrackIds = new List<string> { trackId } };
|
||||||
|
var response = await Http.PostAsJsonAsync($"/api/sharedplaylist/{ShareToken}/add-tracks", request);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
Snackbar.Add("Трек успешно добавлен", Severity.Success);
|
||||||
|
await OnTrackAdded.InvokeAsync(); // уведомляем родителя, что список треков изменился
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var error = await response.Content.ReadFromJsonAsync<ApiResponse<object>>();
|
||||||
|
Snackbar.Add(error?.Error?.Message ?? "Ошибка добавления трека", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Ошибка: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_addingTrack = false;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string? ExtractTrackIdFromLink(string link)
|
||||||
|
{
|
||||||
|
var match = System.Text.RegularExpressions.Regex.Match(link, @"/track/(\d+)");
|
||||||
|
return match.Success ? match.Groups[1].Value : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
@using PlaylistShared.Shared.Enums
|
||||||
|
@using PlaylistShared.Shared.Shared
|
||||||
|
@inject HttpClient Http
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
|
||||||
|
<MudDialog>
|
||||||
|
<TitleContent>
|
||||||
|
<MudText Typo="Typo.h6">Настройки доступа</MudText>
|
||||||
|
</TitleContent>
|
||||||
|
<DialogContent>
|
||||||
|
<MudGrid>
|
||||||
|
<MudItem xs="12" sm="6">
|
||||||
|
<MudSelect T="ViewPermission" Label="Просмотр" @bind-Value="_permissions.ViewPermission" Variant="Variant.Outlined" FullWidth="true">
|
||||||
|
<MudSelectItem Value="ViewPermission.Everyone">Все</MudSelectItem>
|
||||||
|
<MudSelectItem Value="ViewPermission.AuthorizedOnly">Только авторизованные</MudSelectItem>
|
||||||
|
</MudSelect>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" sm="6">
|
||||||
|
<MudSelect T="ViewPermission" Label="Воспроизведение" @bind-Value="_permissions.PlayPermission" Variant="Variant.Outlined" FullWidth="true">
|
||||||
|
<MudSelectItem Value="ViewPermission.Everyone">Все</MudSelectItem>
|
||||||
|
<MudSelectItem Value="ViewPermission.AuthorizedOnly">Только авторизованные</MudSelectItem>
|
||||||
|
</MudSelect>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" sm="6">
|
||||||
|
<MudSelect T="EditPermission" Label="Добавление треков" @bind-Value="_permissions.AddPermission" Variant="Variant.Outlined" FullWidth="true">
|
||||||
|
<MudSelectItem Value="EditPermission.Everyone">Все</MudSelectItem>
|
||||||
|
<MudSelectItem Value="EditPermission.AuthorizedOnly">Только авторизованные</MudSelectItem>
|
||||||
|
<MudSelectItem Value="EditPermission.AddedByUserOnly">Только добавивший</MudSelectItem>
|
||||||
|
</MudSelect>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" sm="6">
|
||||||
|
<MudSelect T="EditPermission" Label="Удаление треков" @bind-Value="_permissions.RemovePermission" Variant="Variant.Outlined" FullWidth="true">
|
||||||
|
<MudSelectItem Value="EditPermission.Everyone">Все</MudSelectItem>
|
||||||
|
<MudSelectItem Value="EditPermission.AuthorizedOnly">Только авторизованные</MudSelectItem>
|
||||||
|
<MudSelectItem Value="EditPermission.AddedByUserOnly">Только добавивший</MudSelectItem>
|
||||||
|
</MudSelect>
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<MudButton Variant="Variant.Text" Color="Color.Default" OnClick="Cancel">Отмена</MudButton>
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="Save" Disabled="_saving">
|
||||||
|
@if (_saving)
|
||||||
|
{
|
||||||
|
<MudProgressCircular Size="Size.Small" Indeterminate />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span>Сохранить</span>
|
||||||
|
}
|
||||||
|
</MudButton>
|
||||||
|
</DialogActions>
|
||||||
|
</MudDialog>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[CascadingParameter] private IMudDialogInstance? MudDialog { get; set; }
|
||||||
|
|
||||||
|
[Parameter] public string ShareToken { get; set; } = string.Empty;
|
||||||
|
[Parameter] public UpdatePermissionsDto InitialPermissions { get; set; } = new();
|
||||||
|
|
||||||
|
[Parameter] public EventCallback<UpdatePermissionsDto> OnPermissionsUpdated { get; set; }
|
||||||
|
|
||||||
|
private UpdatePermissionsDto _permissions = new();
|
||||||
|
private bool _saving;
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
_permissions = new UpdatePermissionsDto
|
||||||
|
{
|
||||||
|
ViewPermission = InitialPermissions.ViewPermission,
|
||||||
|
PlayPermission = InitialPermissions.PlayPermission,
|
||||||
|
AddPermission = InitialPermissions.AddPermission,
|
||||||
|
RemovePermission = InitialPermissions.RemovePermission
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task Save()
|
||||||
|
{
|
||||||
|
_saving = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await Http.PutAsJsonAsync($"/api/sharedplaylist/{ShareToken}/permissions", _permissions);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var result = await response.Content.ReadFromJsonAsync<ApiResponse<SharedPlaylistDto>>();
|
||||||
|
if (result?.Success == true)
|
||||||
|
{
|
||||||
|
Snackbar.Add("Настройки доступа сохранены", Severity.Success);
|
||||||
|
await OnPermissionsUpdated.InvokeAsync(_permissions);
|
||||||
|
MudDialog?.Close(DialogResult.Ok(_permissions));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Snackbar.Add(result?.Error?.Message ?? "Ошибка сохранения", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Snackbar.Add("Ошибка сохранения прав", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Ошибка: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Cancel() => MudDialog?.Cancel();
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
@using PlaylistShared.Pwa.Components.Common
|
||||||
|
@using PlaylistShared.Shared.Shared
|
||||||
|
@using PlaylistShared.Shared.Enums
|
||||||
|
@using System.Security.Claims
|
||||||
|
@inject HttpClient Http
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
@inject AuthenticationStateProvider AuthProvider
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
@inject IDialogService DialogService
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 16px; align-items: center;">
|
||||||
|
@if (!string.IsNullOrEmpty(Playlist?.CoverUrl))
|
||||||
|
{
|
||||||
|
<MudImage Src="@Playlist.CoverUrl.FormatCoverUrl(80, 80)" Height="80" Width="80" Class="rounded" />
|
||||||
|
}
|
||||||
|
<div>
|
||||||
|
<div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap;">
|
||||||
|
<MudLink Href="@($"https://music.yandex.ru/playlists/{Playlist?.YandexPlaylistUuid}")"
|
||||||
|
Typo="Typo.h5"
|
||||||
|
Target="_blank"
|
||||||
|
Underline="Underline.Hover">
|
||||||
|
@Playlist?.Title
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.OpenInNew" Size="Size.Small" Class="ml-1" />
|
||||||
|
</MudLink>
|
||||||
|
|
||||||
|
<ShareButton />
|
||||||
|
|
||||||
|
<MudIconButton Icon="@(_isFavorite? Icons.Material.Filled.Star : Icons.Material.Outlined.StarBorder)"
|
||||||
|
Color="Color.Warning"
|
||||||
|
OnClick="ToggleFavorite"
|
||||||
|
Disabled="_favoriteLoading"
|
||||||
|
Size="Size.Medium" />
|
||||||
|
|
||||||
|
@if (_isCreator && _isAuthenticated)
|
||||||
|
{
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.Settings"
|
||||||
|
Color="Color.Default"
|
||||||
|
OnClick="OpenPermissionsDialog"
|
||||||
|
Title="Настройки доступа"
|
||||||
|
Size="Size.Medium" />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<MudText Typo="Typo.body2" Color="Color.Secondary">Владелец: @Playlist?.Creator?.UserName</MudText>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public SharedPlaylistDto? Playlist { get; set; }
|
||||||
|
[Parameter] public EventCallback OnPermissionsChanged { get; set; }
|
||||||
|
|
||||||
|
private bool _isAuthenticated;
|
||||||
|
private bool _isCreator;
|
||||||
|
private string? _currentUserId;
|
||||||
|
private bool _isFavorite;
|
||||||
|
private bool _favoriteLoading;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
var authState = await AuthProvider.GetAuthenticationStateAsync();
|
||||||
|
_isAuthenticated = authState.User.Identity?.IsAuthenticated == true;
|
||||||
|
_currentUserId = authState.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||||
|
_isCreator = Playlist?.CreatorUserId.ToString() == _currentUserId;
|
||||||
|
if (_isAuthenticated)
|
||||||
|
{
|
||||||
|
await CheckFavoriteStatus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CheckFavoriteStatus()
|
||||||
|
{
|
||||||
|
if (Playlist == null) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await Http.GetFromJsonAsync<ApiResponse<bool>>($"/api/favorites/{Playlist.ShareToken}/check");
|
||||||
|
if (response?.Success == true)
|
||||||
|
_isFavorite = response.Data;
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ToggleFavorite()
|
||||||
|
{
|
||||||
|
if (!_isAuthenticated || Playlist == null) return;
|
||||||
|
_favoriteLoading = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_isFavorite)
|
||||||
|
{
|
||||||
|
var response = await Http.DeleteAsync($"/api/favorites/{Playlist.ShareToken}");
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
_isFavorite = false;
|
||||||
|
Snackbar.Add("Плейлист удалён из избранного", Severity.Success);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Snackbar.Add("Ошибка удаления из избранного", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var response = await Http.PostAsync($"/api/favorites/{Playlist.ShareToken}", null);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
_isFavorite = true;
|
||||||
|
Snackbar.Add("Плейлист добавлен в избранное", Severity.Success);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Snackbar.Add("Ошибка добавления в избранное", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Ошибка: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_favoriteLoading = false;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OpenPermissionsDialog()
|
||||||
|
{
|
||||||
|
if (Playlist == null) return;
|
||||||
|
var initialPermissions = new UpdatePermissionsDto
|
||||||
|
{
|
||||||
|
ViewPermission = Playlist.ViewPermission,
|
||||||
|
PlayPermission = Playlist.PlayPermission,
|
||||||
|
AddPermission = Playlist.AddPermission,
|
||||||
|
RemovePermission = Playlist.RemovePermission
|
||||||
|
};
|
||||||
|
var parameters = new DialogParameters
|
||||||
|
{
|
||||||
|
{ nameof(PermissionsDialog.ShareToken), Playlist.ShareToken },
|
||||||
|
{ nameof(PermissionsDialog.InitialPermissions), initialPermissions }
|
||||||
|
};
|
||||||
|
var dialog = await DialogService.ShowAsync<PermissionsDialog>("Настройки доступа", parameters);
|
||||||
|
var result = await dialog.Result;
|
||||||
|
if (!result.Canceled)
|
||||||
|
{
|
||||||
|
await OnPermissionsChanged.InvokeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
156
PlaylistShared.Pwa/Components/SharedPlaylist/TracksTable.razor
Normal file
156
PlaylistShared.Pwa/Components/SharedPlaylist/TracksTable.razor
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
@using PlaylistShared.Pwa.Components.Common
|
||||||
|
@using PlaylistShared.Shared.DTO
|
||||||
|
@using PlaylistShared.Shared.Shared
|
||||||
|
@inject HttpClient Http
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
@inject IDialogService DialogService
|
||||||
|
|
||||||
|
<MudTable Items="@_tracks" Hover="true" Breakpoint="Breakpoint.Sm" Loading="_loading">
|
||||||
|
<HeaderContent>
|
||||||
|
<MudTh>#</MudTh>
|
||||||
|
<MudTh>Обложка</MudTh>
|
||||||
|
<MudTh>Название</MudTh>
|
||||||
|
<MudTh>Исполнитель</MudTh>
|
||||||
|
<MudTh>Длительность</MudTh>
|
||||||
|
@if (CanRemove)
|
||||||
|
{
|
||||||
|
<MudTh></MudTh>
|
||||||
|
}
|
||||||
|
</HeaderContent>
|
||||||
|
<RowTemplate>
|
||||||
|
<MudTd DataLabel="#" Style="font-weight: normal;">@context.Index</MudTd>
|
||||||
|
<MudTd DataLabel="Обложка">
|
||||||
|
@if (!string.IsNullOrEmpty(context.CoverUri))
|
||||||
|
{
|
||||||
|
@if (CanPlay)
|
||||||
|
{
|
||||||
|
<TrackCoverWithPlay CoverUrl="@context.CoverUri"
|
||||||
|
TrackId="@context.Id"
|
||||||
|
Width="50" Height="50"/>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudImage Src="@context.CoverUri.FormatCoverUrl(50, 50)" Height="50" Width="50" Class="rounded" />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</MudTd>
|
||||||
|
<MudTd DataLabel="Название">
|
||||||
|
<MudLink Href="@($"https://music.yandex.ru/track/{context.Id}")" Target="_blank" Underline="Underline.Hover">
|
||||||
|
@context.Title
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.OpenInNew" Size="Size.Small" Class="ml-1" />
|
||||||
|
</MudLink>
|
||||||
|
</MudTd>
|
||||||
|
<MudTd DataLabel="Исполнитель">@string.Join(", ", context.Artists)</MudTd>
|
||||||
|
<MudTd DataLabel="Длительность">@FormatDuration(context.DurationMs)</MudTd>
|
||||||
|
@if (CanRemove)
|
||||||
|
{
|
||||||
|
<MudTd DataLabel="">
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" OnClick="() => RemoveTrack(context)" />
|
||||||
|
</MudTd>
|
||||||
|
}
|
||||||
|
</RowTemplate>
|
||||||
|
</MudTable>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public string ShareToken { get; set; } = string.Empty;
|
||||||
|
[Parameter] public bool CanPlay { get; set; }
|
||||||
|
[Parameter] public bool CanRemove { get; set; }
|
||||||
|
[Parameter] public string? CurrentPlayingTrackId { get; set; }
|
||||||
|
[Parameter] public bool IsPlaying { get; set; }
|
||||||
|
[Parameter] public EventCallback<string> OnPlayTrack { get; set; }
|
||||||
|
|
||||||
|
public async Task Reload()
|
||||||
|
{
|
||||||
|
await LoadTracks();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<TrackDisplay> _tracks = new();
|
||||||
|
private bool _loading = true;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
await LoadTracks();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadTracks()
|
||||||
|
{
|
||||||
|
_loading = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await Http.GetFromJsonAsync<ApiResponse<YandexPlaylistData>>($"/api/sharedplaylist/{ShareToken}/tracks");
|
||||||
|
if (response?.Success == true && response.Data != null)
|
||||||
|
{
|
||||||
|
_tracks = response.Data.Tracks.Select((t, idx) => new TrackDisplay
|
||||||
|
{
|
||||||
|
Id = t.Id,
|
||||||
|
Title = t.Title,
|
||||||
|
Artists = t.Artists,
|
||||||
|
DurationMs = t.DurationMs,
|
||||||
|
CoverUri = t.CoverUri,
|
||||||
|
Index = idx + 1
|
||||||
|
}).ToList();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Snackbar.Add(response?.Error?.Message ?? "Не удалось загрузить треки", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Ошибка загрузки треков: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_loading = false;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RemoveTrack(TrackDisplay track)
|
||||||
|
{
|
||||||
|
var confirmed = await DialogService.ShowMessageBoxAsync(
|
||||||
|
"Подтверждение удаления",
|
||||||
|
$"Вы уверены, что хотите удалить трек \"{track.Title}\"?",
|
||||||
|
yesText: "Удалить", cancelText: "Отмена");
|
||||||
|
|
||||||
|
if (confirmed != true) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var request = new RemoveTracksRequest { TrackIds = new List<string> { track.Id } };
|
||||||
|
var response = await Http.PostAsJsonAsync($"/api/sharedplaylist/{ShareToken}/remove-tracks", request);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
Snackbar.Add("Трек удалён", Severity.Success);
|
||||||
|
await LoadTracks();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var error = await response.Content.ReadFromJsonAsync<ApiResponse<object>>();
|
||||||
|
Snackbar.Add(error?.Error?.Message ?? "Ошибка удаления", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Ошибка: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PlayTrack(string trackId)
|
||||||
|
{
|
||||||
|
await OnPlayTrack.InvokeAsync(trackId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string FormatDuration(long ms)
|
||||||
|
{
|
||||||
|
var seconds = ms / 1000;
|
||||||
|
var mins = seconds / 60;
|
||||||
|
var secs = seconds % 60;
|
||||||
|
return $"{mins}:{secs:D2}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private class TrackDisplay : YandexTrack
|
||||||
|
{
|
||||||
|
public int Index { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
19
PlaylistShared.Pwa/Extensions/StringExtensions.cs
Normal file
19
PlaylistShared.Pwa/Extensions/StringExtensions.cs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
namespace PlaylistShared.Pwa.Extensions;
|
||||||
|
|
||||||
|
public static class StringExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Преобразует шаблон URL обложки Яндекс.Музыки в полный URL с указанным размером.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="coverUri">Шаблон URL (например, "avatars.yandex.net/get-music-content/.../%%")</param>
|
||||||
|
/// <param name="width">Желаемая ширина обложки (по умолчанию 200)</param>
|
||||||
|
/// <param name="height">Желаемая высота обложки (по умолчанию 200)</param>
|
||||||
|
/// <returns>Полный URL обложки или пустую строку, если входная строка null или пуста.</returns>
|
||||||
|
public static string FormatCoverUrl(this string? coverUri, int width = 200, int height = 200)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(coverUri))
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
return "https://" + coverUri.Replace("%%", $"{width}x{height}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
@using PlaylistShared.Pwa.Components.Global
|
||||||
@inherits LayoutComponentBase
|
@inherits LayoutComponentBase
|
||||||
|
|
||||||
<MudThemeProvider Theme="@_theme" IsDarkMode="_isDarkMode" />
|
<MudThemeProvider Theme="@_theme" IsDarkMode="_isDarkMode" />
|
||||||
@@ -21,8 +22,13 @@
|
|||||||
<NavMenu />
|
<NavMenu />
|
||||||
</MudDrawer>
|
</MudDrawer>
|
||||||
|
|
||||||
<MudMainContent Class="pt-16 pa-4">
|
<MudMainContent Class="pt-16 pa-4" Style="display: flex; flex-direction: column; min-height: 100vh;">
|
||||||
|
<div style="flex: 1;">
|
||||||
@Body
|
@Body
|
||||||
|
</div>
|
||||||
|
<div style="width: 100%; margin-top: 16px;">
|
||||||
|
<AudioPlayer />
|
||||||
|
</div>
|
||||||
</MudMainContent>
|
</MudMainContent>
|
||||||
</MudLayout>
|
</MudLayout>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
@page "/shared/{token}"
|
@page "/shared/{token}"
|
||||||
|
@using PlaylistShared.Pwa.Components.SharedPlaylist
|
||||||
@using PlaylistShared.Shared.DTO
|
@using PlaylistShared.Shared.DTO
|
||||||
@using PlaylistShared.Shared.Enums
|
@using PlaylistShared.Shared.Enums
|
||||||
@using PlaylistShared.Pwa.Services
|
@using PlaylistShared.Pwa.Services
|
||||||
@@ -24,108 +25,16 @@
|
|||||||
<!-- Заголовок с обложкой -->
|
<!-- Заголовок с обложкой -->
|
||||||
<MudCardHeader>
|
<MudCardHeader>
|
||||||
<CardHeaderContent>
|
<CardHeaderContent>
|
||||||
<div style="display: flex; gap: 16px; align-items: center;">
|
<PlaylistHeader Playlist="@_playlist" />
|
||||||
@if (!string.IsNullOrEmpty(_playlist.CoverUrl))
|
|
||||||
{
|
|
||||||
<MudImage Src="@FormatCoverUrl(_playlist.CoverUrl, "80x80")" Height="80" Width="80" Class="rounded" />
|
|
||||||
}
|
|
||||||
<div>
|
|
||||||
<div style="display: flex; align-items: center; gap: 8px;">
|
|
||||||
<MudLink Href="@($"https://music.yandex.ru/playlists/{_playlist.YandexPlaylistUuid}")" Typo="Typo.h5" Target="_blank" Underline="Underline.Hover">
|
|
||||||
@_playlist.Title
|
|
||||||
<MudIcon Icon="@Icons.Material.Filled.OpenInNew" Size="Size.Small" Class="ml-1" />
|
|
||||||
</MudLink>
|
|
||||||
|
|
||||||
<MudIconButton Icon="@(_isFavorite? Icons.Material.Filled.Star : Icons.Material.Outlined.StarBorder)"
|
|
||||||
Color="Color.Warning"
|
|
||||||
OnClick="ToggleFavorite"
|
|
||||||
Disabled="_favoriteLoading"
|
|
||||||
Size="Size.Medium" />
|
|
||||||
</div>
|
|
||||||
<MudText Typo="Typo.body2" Color="Color.Secondary">Владелец: @_playlist.Creator?.UserName</MudText>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeaderContent>
|
</CardHeaderContent>
|
||||||
</MudCardHeader>
|
</MudCardHeader>
|
||||||
|
|
||||||
<MudCardContent>
|
<MudCardContent>
|
||||||
<!-- Настройки доступа (только для создателя, который авторизован) -->
|
|
||||||
@if (_isCreator && _isAuthenticated)
|
|
||||||
{
|
|
||||||
<MudPaper Class="pa-4 mb-4" Elevation="0" Style="background-color: rgba(0,0,0,0.05); border-radius: 8px;">
|
|
||||||
<MudText Typo="Typo.h6" GutterBottom>Настройки доступа</MudText>
|
|
||||||
<MudGrid>
|
|
||||||
<MudItem xs="12" sm="4">
|
|
||||||
<MudSelect T="ViewPermission" Label="Просмотр" @bind-Value="_editPermissions.ViewPermission" Variant="Variant.Outlined" FullWidth="true">
|
|
||||||
<MudSelectItem Value="ViewPermission.Everyone">Все</MudSelectItem>
|
|
||||||
<MudSelectItem Value="ViewPermission.AuthorizedOnly">Только авторизованные</MudSelectItem>
|
|
||||||
</MudSelect>
|
|
||||||
</MudItem>
|
|
||||||
<MudItem xs="12" sm="4">
|
|
||||||
<MudSelect T="ViewPermission" Label="Воспроизведение" @bind-Value="_editPermissions.PlayPermission" Variant="Variant.Outlined" FullWidth="true">
|
|
||||||
<MudSelectItem Value="ViewPermission.Everyone">Все</MudSelectItem>
|
|
||||||
<MudSelectItem Value="ViewPermission.AuthorizedOnly">Только авторизованные</MudSelectItem>
|
|
||||||
</MudSelect>
|
|
||||||
</MudItem>
|
|
||||||
<MudItem xs="12" sm="4">
|
|
||||||
<MudSelect T="EditPermission" Label="Добавление треков" @bind-Value="_editPermissions.AddPermission" Variant="Variant.Outlined" FullWidth="true">
|
|
||||||
<MudSelectItem Value="EditPermission.Everyone">Все</MudSelectItem>
|
|
||||||
<MudSelectItem Value="EditPermission.AuthorizedOnly">Только авторизованные</MudSelectItem>
|
|
||||||
<MudSelectItem Value="EditPermission.AddedByUserOnly">Только добавивший</MudSelectItem>
|
|
||||||
</MudSelect>
|
|
||||||
</MudItem>
|
|
||||||
<MudItem xs="12" sm="4">
|
|
||||||
<MudSelect T="EditPermission" Label="Удаление треков" @bind-Value="_editPermissions.RemovePermission" Variant="Variant.Outlined" FullWidth="true">
|
|
||||||
<MudSelectItem Value="EditPermission.Everyone">Все</MudSelectItem>
|
|
||||||
<MudSelectItem Value="EditPermission.AuthorizedOnly">Только авторизованные</MudSelectItem>
|
|
||||||
<MudSelectItem Value="EditPermission.AddedByUserOnly">Только добавивший</MudSelectItem>
|
|
||||||
</MudSelect>
|
|
||||||
</MudItem>
|
|
||||||
</MudGrid>
|
|
||||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SavePermissions" Disabled="_savingPermissions">
|
|
||||||
@if (_savingPermissions)
|
|
||||||
{
|
|
||||||
<MudProgressCircular Size="Size.Small" Indeterminate />
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
|
|
||||||
<span>Сохранить</span>
|
|
||||||
}
|
|
||||||
</MudButton>
|
|
||||||
</MudPaper>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Блок добавления трека (только для авторизованных с правом добавления) -->
|
|
||||||
@if (_canAdd)
|
@if (_canAdd)
|
||||||
{
|
{
|
||||||
<MudPaper Class="pa-4 mb-4" Elevation="0" Style="background-color: rgba(0,0,0,0.05); border-radius: 8px;">
|
<AddTrackSection ShareToken="@Token"
|
||||||
<MudText Typo="Typo.h6" GutterBottom>Добавить трек</MudText>
|
OnTrackAdded="LoadTracks"
|
||||||
<MudGrid>
|
/>
|
||||||
<MudItem xs="12" sm="10">
|
|
||||||
<MudTextField @bind-Value="_trackLink" Label="Ссылка на трек Яндекс.Музыки"
|
|
||||||
Variant="Variant.Outlined" FullWidth="true"
|
|
||||||
Placeholder="https://music.yandex.ru/album/2488464/track/21696942" />
|
|
||||||
</MudItem>
|
|
||||||
<MudItem xs="12" sm="2">
|
|
||||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="AddTrack"
|
|
||||||
Disabled="_addingTrack" FullWidth="true" Style="height: 100%;">
|
|
||||||
@if (_addingTrack)
|
|
||||||
{
|
|
||||||
<MudProgressCircular Size="Size.Small" Indeterminate />
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
|
|
||||||
<span>Добавить</span>
|
|
||||||
}
|
|
||||||
</MudButton>
|
|
||||||
</MudItem>
|
|
||||||
</MudGrid>
|
|
||||||
<MudText Typo="Typo.body2" Class="mt-2" Color="Color.Secondary">
|
|
||||||
Поддерживаются ссылки вида: https://music.yandex.ru/album/12345/track/67890
|
|
||||||
</MudText>
|
|
||||||
</MudPaper>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- Список треков -->
|
<!-- Список треков -->
|
||||||
@@ -134,83 +43,24 @@
|
|||||||
<MudIconButton Icon="@Icons.Material.Filled.Refresh" OnClick="LoadTracks" Disabled="_tracksLoading" Size="Size.Medium" />
|
<MudIconButton Icon="@Icons.Material.Filled.Refresh" OnClick="LoadTracks" Disabled="_tracksLoading" Size="Size.Medium" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (_tracksLoading)
|
<TracksTable @ref="_tracksTableRef"
|
||||||
{
|
ShareToken="@Token"
|
||||||
<MudProgressCircular Indeterminate />
|
CanPlay="@_canPlay"
|
||||||
}
|
CanRemove="@_canRemove"
|
||||||
else if (_tracks == null || !_tracks.Any())
|
CurrentPlayingTrackId="_currentTrackId"
|
||||||
{
|
/>
|
||||||
<MudAlert Severity="Severity.Info">В плейлисте пока нет треков</MudAlert>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<MudTable Items="@_tracks">
|
|
||||||
<HeaderContent>
|
|
||||||
<MudTh>#</MudTh>
|
|
||||||
<MudTh>Обложка</MudTh>
|
|
||||||
<MudTh>Название</MudTh>
|
|
||||||
<MudTh>Исполнитель</MudTh>
|
|
||||||
<MudTh>Длительность</MudTh>
|
|
||||||
@if (_canRemove)
|
|
||||||
{
|
|
||||||
<MudTh></MudTh>
|
|
||||||
}
|
|
||||||
</HeaderContent>
|
|
||||||
<RowTemplate>
|
|
||||||
<MudTd DataLabel="#" Style="font-weight: normal;">@context.Index</MudTd>
|
|
||||||
<MudTd DataLabel="Обложка">
|
|
||||||
@if (!string.IsNullOrEmpty(context.CoverUri))
|
|
||||||
{
|
|
||||||
@if (@_canPlay)
|
|
||||||
{
|
|
||||||
<TrackCoverWithPlay CoverUrl="@context.CoverUri"
|
|
||||||
TrackId="@context.Id"
|
|
||||||
Width="50" Height="50"
|
|
||||||
IsPlaying="@(_currentTrackId == context.Id && _isPlaying)"
|
|
||||||
OnPlay="PlayTrack" />
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
|
|
||||||
<MudImage Src="@FormatCoverUrl(context.CoverUri, "50x50")" Height="50" Width="50" Class="rounded" Style="display: block;" />
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</MudTd>
|
|
||||||
<MudTd DataLabel="Название">
|
|
||||||
<MudLink Href="@($"https://music.yandex.ru/track/{context.Id}")" Target="_blank" Underline="Underline.Hover">
|
|
||||||
@context.Title
|
|
||||||
<MudIcon Icon="@Icons.Material.Filled.OpenInNew" Size="Size.Small" Class="ml-1" />
|
|
||||||
</MudLink>
|
|
||||||
</MudTd>
|
|
||||||
<MudTd DataLabel="Исполнитель">@string.Join(", ", context.Artists)</MudTd>
|
|
||||||
<MudTd DataLabel="Длительность">@FormatDuration(context.DurationMs)</MudTd>
|
|
||||||
@if (_canRemove)
|
|
||||||
{
|
|
||||||
<MudTd DataLabel="">
|
|
||||||
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" OnClick="() => RemoveTrack(context)" />
|
|
||||||
</MudTd>
|
|
||||||
}
|
|
||||||
</RowTemplate>
|
|
||||||
</MudTable>
|
|
||||||
}
|
|
||||||
</MudCardContent>
|
</MudCardContent>
|
||||||
</MudCard>
|
</MudCard>
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- Фиксированный плеер внизу -->
|
|
||||||
<div class="fixed-player" style="display: @(_isPlayerVisible ? "block" : "none");">
|
|
||||||
<AudioPlayer @ref="_audioPlayer" OnTrackEnded="OnTrackEnded" RequireAuth="false" SharedPlaylistId="@Token"/>
|
|
||||||
</div>
|
|
||||||
</MudContainer>
|
</MudContainer>
|
||||||
|
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Parameter] public string Token { get; set; }
|
[Parameter] public string Token { get; set; }
|
||||||
|
|
||||||
private AudioPlayer? _audioPlayer;
|
private int _addTrackTabIndex = 0; // 0 - ссылка, 1 - поиск
|
||||||
private string? _currentTrackId { get; set; }
|
|
||||||
private bool _isPlaying = false;
|
private TracksTable? _tracksTableRef;
|
||||||
private bool _isPlayerVisible = false;
|
|
||||||
|
|
||||||
private SharedPlaylistDto? _playlist;
|
private SharedPlaylistDto? _playlist;
|
||||||
private bool _loading = true;
|
private bool _loading = true;
|
||||||
@@ -225,8 +75,6 @@
|
|||||||
|
|
||||||
private bool _isFavorite = false;
|
private bool _isFavorite = false;
|
||||||
private bool _favoriteLoading = false;
|
private bool _favoriteLoading = false;
|
||||||
|
|
||||||
private List<YandexTrackDisplay> _tracks = new();
|
|
||||||
private bool _tracksLoading;
|
private bool _tracksLoading;
|
||||||
|
|
||||||
private string _trackLink = "";
|
private string _trackLink = "";
|
||||||
@@ -240,67 +88,6 @@
|
|||||||
await LoadPlaylist();
|
await LoadPlaylist();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task CheckFavoriteStatus()
|
|
||||||
{
|
|
||||||
if (!_isAuthenticated || _playlist == null) return;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var response = await Http.GetFromJsonAsync<ApiResponse<bool>>($"/api/favorites/{Token}/check");
|
|
||||||
if (response?.Success == true)
|
|
||||||
_isFavorite = response.Data;
|
|
||||||
}
|
|
||||||
catch { }
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task ToggleFavorite()
|
|
||||||
{
|
|
||||||
if (!_isAuthenticated)
|
|
||||||
{
|
|
||||||
Snackbar.Add("Добавление в избранное доступно только авторизованным пользователям", Severity.Warning);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_favoriteLoading = true;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (_isFavorite)
|
|
||||||
{
|
|
||||||
var response = await Http.DeleteAsync($"/api/favorites/{Token}");
|
|
||||||
if (response.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
_isFavorite = false;
|
|
||||||
Snackbar.Add("Плейлист удалён из избранного", Severity.Success);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Snackbar.Add("Ошибка удаления из избранного", Severity.Error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var response = await Http.PostAsync($"/api/favorites/{Token}", null);
|
|
||||||
if (response.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
_isFavorite = true;
|
|
||||||
Snackbar.Add("Плейлист добавлен в избранное", Severity.Success);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Snackbar.Add("Ошибка добавления в избранное", Severity.Error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Snackbar.Add($"Ошибка: {ex.Message}", Severity.Error);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_favoriteLoading = false;
|
|
||||||
StateHasChanged();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task ConfigurePermissions()
|
private async Task ConfigurePermissions()
|
||||||
{
|
{
|
||||||
if (_playlist is null)
|
if (_playlist is null)
|
||||||
@@ -350,8 +137,7 @@
|
|||||||
_playlist = response.Data;
|
_playlist = response.Data;
|
||||||
|
|
||||||
await ConfigurePermissions();
|
await ConfigurePermissions();
|
||||||
await LoadTracks();
|
//await LoadTracks();
|
||||||
await CheckFavoriteStatus();
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -372,190 +158,14 @@
|
|||||||
private async Task LoadTracks()
|
private async Task LoadTracks()
|
||||||
{
|
{
|
||||||
if (_playlist == null) return;
|
if (_playlist == null) return;
|
||||||
|
if (_tracksTableRef == null) return;
|
||||||
|
|
||||||
_tracksLoading = true;
|
_tracksLoading = true;
|
||||||
try
|
StateHasChanged();
|
||||||
{
|
|
||||||
var url = $"/api/sharedplaylist/{Token}/tracks";
|
await _tracksTableRef.Reload();
|
||||||
var response = await Http.GetFromJsonAsync<ApiResponse<YandexPlaylistData>>(url);
|
|
||||||
if (response?.Success == true && response.Data != null)
|
|
||||||
{
|
|
||||||
_tracks = response.Data.Tracks.Select((t, idx) => new YandexTrackDisplay
|
|
||||||
{
|
|
||||||
Id = t.Id,
|
|
||||||
Title = t.Title,
|
|
||||||
Artists = t.Artists,
|
|
||||||
DurationMs = t.DurationMs,
|
|
||||||
CoverUri = t.CoverUri,
|
|
||||||
Index = idx + 1
|
|
||||||
}).ToList();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Snackbar.Add(response?.Error?.Message ?? "Не удалось загрузить треки", Severity.Error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Snackbar.Add($"Ошибка загрузки треков: {ex.Message}", Severity.Error);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_tracksLoading = false;
|
_tracksLoading = false;
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private async Task AddTrack()
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(_trackLink))
|
|
||||||
{
|
|
||||||
Snackbar.Add("Введите ссылку на трек", Severity.Warning);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_addingTrack = true;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var request = new AddTrackByLinkRequest { Link = _trackLink };
|
|
||||||
var response = await Http.PostAsJsonAsync($"/api/sharedplaylist/{Token}/add-track-by-link", request);
|
|
||||||
if (response.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
Snackbar.Add("Трек успешно добавлен", Severity.Success);
|
|
||||||
_trackLink = "";
|
|
||||||
await LoadTracks();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var error = await response.Content.ReadFromJsonAsync<ApiResponse<object>>();
|
|
||||||
Snackbar.Add(error?.Error?.Message ?? "Ошибка добавления трека", Severity.Error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Snackbar.Add($"Ошибка: {ex.Message}", Severity.Error);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_addingTrack = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task RemoveTrack(YandexTrackDisplay track)
|
|
||||||
{
|
|
||||||
var confirmed = await DialogService.ShowMessageBoxAsync(
|
|
||||||
"Подтверждение удаления",
|
|
||||||
$"Вы уверены, что хотите удалить трек \"{track.Title}\"?",
|
|
||||||
yesText: "Удалить", cancelText: "Отмена");
|
|
||||||
|
|
||||||
if (confirmed != true) return;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var request = new RemoveTracksRequest { TrackIds = new List<string> { track.Id } };
|
|
||||||
var response = await Http.PostAsJsonAsync($"/api/sharedplaylist/{Token}/remove-tracks", request);
|
|
||||||
if (response.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
Snackbar.Add("Трек удалён", Severity.Success);
|
|
||||||
await LoadTracks();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var error = await response.Content.ReadFromJsonAsync<ApiResponse<object>>();
|
|
||||||
Snackbar.Add(error?.Error?.Message ?? "Ошибка удаления", Severity.Error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Snackbar.Add($"Ошибка: {ex.Message}", Severity.Error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task SavePermissions()
|
|
||||||
{
|
|
||||||
if (!_isAuthenticated) return;
|
|
||||||
_savingPermissions = true;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var response = await Http.PutAsJsonAsync($"/api/sharedplaylist/{Token}/permissions", _editPermissions);
|
|
||||||
if (response.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
var result = await response.Content.ReadFromJsonAsync<ApiResponse<SharedPlaylistDto>>();
|
|
||||||
if (result?.Success == true)
|
|
||||||
{
|
|
||||||
_playlist = result.Data;
|
|
||||||
|
|
||||||
await ConfigurePermissions();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Snackbar.Add(result?.Error?.Message ?? "Ошибка обновления", Severity.Error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Snackbar.Add("Ошибка сохранения прав", Severity.Error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Snackbar.Add($"Ошибка: {ex.Message}", Severity.Error);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_savingPermissions = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private string FormatDuration(int ms)
|
|
||||||
{
|
|
||||||
var seconds = ms / 1000;
|
|
||||||
var mins = seconds / 60;
|
|
||||||
var secs = seconds % 60;
|
|
||||||
return $"{mins}:{secs:D2}";
|
|
||||||
}
|
|
||||||
|
|
||||||
private string FormatCoverUrl(string? url, string size = "200x200")
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(url)) return "";
|
|
||||||
return "https://" + url.Replace("%%", size);
|
|
||||||
}
|
|
||||||
|
|
||||||
private class YandexTrackDisplay : YandexTrack
|
|
||||||
{
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
_isPlayerVisible = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task OnTrackEnded()
|
|
||||||
{
|
|
||||||
_currentTrackId = null;
|
|
||||||
_isPlaying = false;
|
|
||||||
StateHasChanged();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -25,6 +25,7 @@ internal class Program
|
|||||||
builder.Services.AddScoped<AuthStateProvider>();
|
builder.Services.AddScoped<AuthStateProvider>();
|
||||||
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.AddScoped<IAudioPlayerService, AudioPlayerService>();
|
||||||
|
|
||||||
builder.Services.AddAuthorizationCore();
|
builder.Services.AddAuthorizationCore();
|
||||||
builder.Services.AddCascadingAuthenticationState();
|
builder.Services.AddCascadingAuthenticationState();
|
||||||
|
|||||||
186
PlaylistShared.Pwa/Services/AudioPlayerService.cs
Normal file
186
PlaylistShared.Pwa/Services/AudioPlayerService.cs
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
using MudBlazor;
|
||||||
|
using PlaylistShared.Shared;
|
||||||
|
using PlaylistShared.Shared.DTO;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
|
||||||
|
namespace PlaylistShared.Pwa.Services;
|
||||||
|
|
||||||
|
public class AudioPlayerService : IAudioPlayerService
|
||||||
|
{
|
||||||
|
private readonly TokenStorage _tokenStorage;
|
||||||
|
private readonly ISnackbar _snackbar;
|
||||||
|
private readonly HttpClient _http;
|
||||||
|
|
||||||
|
private string? _currentTrackId;
|
||||||
|
private string? _currentTrackTitle;
|
||||||
|
private string? _currentTrackCoverUrl;
|
||||||
|
private bool _isPlaying;
|
||||||
|
private double _currentVolume = 70;
|
||||||
|
private double _currentProgress;
|
||||||
|
private string _currentTime = "0:00";
|
||||||
|
private string _totalTime = "0:00";
|
||||||
|
|
||||||
|
public string? CurrentTrackId => _currentTrackId;
|
||||||
|
public string? CurrentTrackTitle => _currentTrackTitle;
|
||||||
|
public string? CurrentTrackCoverUrl => _currentTrackCoverUrl;
|
||||||
|
public bool IsPlaying => _isPlaying;
|
||||||
|
public double CurrentVolume
|
||||||
|
{
|
||||||
|
get => _currentVolume;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
_currentVolume = value;
|
||||||
|
OnStateChanged?.Invoke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public double CurrentProgress => _currentProgress;
|
||||||
|
public string CurrentTime => _currentTime;
|
||||||
|
public string TotalTime => _totalTime;
|
||||||
|
|
||||||
|
public event Action? OnStateChanged;
|
||||||
|
|
||||||
|
public AudioPlayerService(TokenStorage tokenStorage, ISnackbar snackbar, HttpClient httpClient)
|
||||||
|
{
|
||||||
|
_tokenStorage = tokenStorage;
|
||||||
|
_snackbar = snackbar;
|
||||||
|
_http = httpClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Внешние команды (вызываются из компонентов)
|
||||||
|
public async Task LoadAndPlayAsync(string trackId, string? accessToken = null, string? sharedPlaylistId = null, string? title = null, string? coverUrl = null)
|
||||||
|
{
|
||||||
|
// Если accessToken не передан, пытаемся получить его из хранилища
|
||||||
|
if (string.IsNullOrWhiteSpace(accessToken))
|
||||||
|
{
|
||||||
|
var tokens = await _tokenStorage.GetTokensAsync();
|
||||||
|
accessToken = tokens.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, есть ли чем авторизоваться
|
||||||
|
if (string.IsNullOrWhiteSpace(accessToken) && string.IsNullOrWhiteSpace(sharedPlaylistId))
|
||||||
|
{
|
||||||
|
_snackbar.Add("Не удалось воспроизвести трек: отсутствует токен авторизации или идентификатор расшаренного плейлиста.", Severity.Error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если title и coverUrl не переданы, нужно запросить через API
|
||||||
|
if (string.IsNullOrEmpty(title) || string.IsNullOrEmpty(coverUrl))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var trackInfo = await GetTrackInfo(trackId, accessToken, sharedPlaylistId);
|
||||||
|
title = trackInfo?.Title;
|
||||||
|
coverUrl = trackInfo?.CoverUri;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Логируем ошибку, но продолжаем без обложки/названия
|
||||||
|
Console.WriteLine($"Failed to fetch track info: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_currentTrackId = trackId;
|
||||||
|
_currentTrackTitle = title ?? "Неизвестный трек";
|
||||||
|
_currentTrackCoverUrl = coverUrl;
|
||||||
|
_isPlaying = true;
|
||||||
|
OnStateChanged?.Invoke();
|
||||||
|
OnLoadAndPlayRequested?.Invoke(trackId, accessToken, sharedPlaylistId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task PlayAsync()
|
||||||
|
{
|
||||||
|
_isPlaying = true;
|
||||||
|
OnStateChanged?.Invoke();
|
||||||
|
OnPlayRequested?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task PauseAsync()
|
||||||
|
{
|
||||||
|
_isPlaying = false;
|
||||||
|
OnStateChanged?.Invoke();
|
||||||
|
OnPauseRequested?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task StopAsync()
|
||||||
|
{
|
||||||
|
_isPlaying = false;
|
||||||
|
_currentTrackId = null;
|
||||||
|
_currentProgress = 0;
|
||||||
|
_currentTime = "0:00";
|
||||||
|
OnStateChanged?.Invoke();
|
||||||
|
OnStopRequested?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SeekToAsync(double percent)
|
||||||
|
{
|
||||||
|
OnSeekRequested?.Invoke(percent);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SetVolumeAsync(double volume)
|
||||||
|
{
|
||||||
|
_currentVolume = volume;
|
||||||
|
OnStateChanged?.Invoke();
|
||||||
|
OnVolumeChangeRequested?.Invoke(volume);
|
||||||
|
}
|
||||||
|
|
||||||
|
// События для связи с реальным AudioPlayer компонентом
|
||||||
|
public event Func<string, string?, string?, Task>? OnLoadAndPlayRequested;
|
||||||
|
public event Func<Task>? OnPlayRequested;
|
||||||
|
public event Func<Task>? OnPauseRequested;
|
||||||
|
public event Func<Task>? OnStopRequested;
|
||||||
|
public event Func<double, Task>? OnSeekRequested;
|
||||||
|
public event Func<double, Task>? OnVolumeChangeRequested;
|
||||||
|
|
||||||
|
// Внутренние методы для обновления состояния из AudioPlayer
|
||||||
|
public void SetPlayingState(bool isPlaying)
|
||||||
|
{
|
||||||
|
_isPlaying = isPlaying;
|
||||||
|
OnStateChanged?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetCurrentTrack(string? trackId)
|
||||||
|
{
|
||||||
|
_currentTrackId = trackId;
|
||||||
|
OnStateChanged?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void UpdateProgress(double progress, string currentTime, string totalTime)
|
||||||
|
{
|
||||||
|
_currentProgress = progress;
|
||||||
|
_currentTime = currentTime;
|
||||||
|
_totalTime = totalTime;
|
||||||
|
OnStateChanged?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void NotifyTrackEnded()
|
||||||
|
{
|
||||||
|
_isPlaying = false;
|
||||||
|
_currentTrackId = null;
|
||||||
|
_currentProgress = 0;
|
||||||
|
_currentTime = "0:00";
|
||||||
|
OnStateChanged?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Вспомогательный метод для получения информации о треке через API
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="trackId"></param>
|
||||||
|
/// <param name="accessToken"></param>
|
||||||
|
/// <param name="sharedPlaylistId"></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
private async Task<(string? Title, string? CoverUri)?> GetTrackInfo(string trackId, string? accessToken, string? sharedPlaylistId)
|
||||||
|
{
|
||||||
|
var url = $"/api/audio/track-info/{trackId}";
|
||||||
|
if (!string.IsNullOrEmpty(accessToken))
|
||||||
|
url += $"?access_token={accessToken}";
|
||||||
|
else if (!string.IsNullOrEmpty(sharedPlaylistId))
|
||||||
|
url += $"?shared_id={sharedPlaylistId}";
|
||||||
|
|
||||||
|
var response = await _http.GetFromJsonAsync<ApiResponse<TrackInfoDto>>(url);
|
||||||
|
if (response?.Success == true)
|
||||||
|
{
|
||||||
|
return (response.Data.Title, response.Data.CoverUri);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
102
PlaylistShared.Pwa/Services/IAudioPlayerService.cs
Normal file
102
PlaylistShared.Pwa/Services/IAudioPlayerService.cs
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
namespace PlaylistShared.Pwa.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Глобальный сервис управления аудиоплеером.
|
||||||
|
/// Позволяет управлять воспроизведением из любого компонента.
|
||||||
|
/// </summary>
|
||||||
|
public interface IAudioPlayerService
|
||||||
|
{
|
||||||
|
#region Состояние плеера (для чтения)
|
||||||
|
/// <summary>ID текущего воспроизводимого трека (null, если ничего не играет).</summary>
|
||||||
|
string? CurrentTrackId { get; }
|
||||||
|
|
||||||
|
/// <summary>Играет ли в данный момент (true) или приостановлен (false).</summary>
|
||||||
|
bool IsPlaying { get; }
|
||||||
|
|
||||||
|
/// <summary>Текущая громкость (0–100).</summary>
|
||||||
|
double CurrentVolume { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Прогресс воспроизведения в процентах (0–100).</summary>
|
||||||
|
double CurrentProgress { get; }
|
||||||
|
|
||||||
|
/// <summary>Отформатированное текущее время (мм:сс).</summary>
|
||||||
|
string CurrentTime { get; }
|
||||||
|
|
||||||
|
/// <summary>Отформатированная общая длительность (мм:сс).</summary>
|
||||||
|
string TotalTime { get; }
|
||||||
|
|
||||||
|
/// <summary>Отформатированное название текущего трека.</summary>
|
||||||
|
string? CurrentTrackTitle { get; }
|
||||||
|
|
||||||
|
/// <summary>URL обложки текущего трека.</summary>
|
||||||
|
string? CurrentTrackCoverUrl { get; }
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Команды управления (вызываются из компонентов)
|
||||||
|
/// <summary>Загрузить и начать воспроизведение трека.</summary>
|
||||||
|
/// <param name="trackId">ID трека.</param>
|
||||||
|
/// <param name="accessToken">Опциональный access-токен (если не указан, будет взят из хранилища).</param>
|
||||||
|
/// <param name="sharedPlaylistId">ID расшаренного плейлиста (для неавторизованного доступа).</param>
|
||||||
|
/// <param name="title">Название трека. (Если не передано, вызывает api для получения)</param>
|
||||||
|
/// <param name="coverUrl">URL обложки трека. (Если не передано, вызывает api для получения)</param>
|
||||||
|
Task LoadAndPlayAsync(string trackId, string? accessToken = null, string? sharedPlaylistId = null, string? title = null, string? coverUrl = null);
|
||||||
|
|
||||||
|
/// <summary>Воспроизвести (если трек загружен и на паузе).</summary>
|
||||||
|
Task PlayAsync();
|
||||||
|
|
||||||
|
/// <summary>Поставить на паузу.</summary>
|
||||||
|
Task PauseAsync();
|
||||||
|
|
||||||
|
/// <summary>Остановить воспроизведение и выгрузить трек.</summary>
|
||||||
|
Task StopAsync();
|
||||||
|
|
||||||
|
/// <summary>Перемотать на указанный процент (0–100).</summary>
|
||||||
|
Task SeekToAsync(double percent);
|
||||||
|
|
||||||
|
/// <summary>Установить громкость (0–100).</summary>
|
||||||
|
Task SetVolumeAsync(double volume);
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region События для подписки на изменения состояния
|
||||||
|
/// <summary>
|
||||||
|
/// Событие, возникающее при любом изменении состояния плеера:
|
||||||
|
/// смена трека, старт/пауза/стоп, обновление прогресса, изменение громкости, окончание трека.
|
||||||
|
/// Подписывайтесь на него, чтобы перерисовывать UI (например, иконку "пауза/плей").
|
||||||
|
/// </summary>
|
||||||
|
event Action? OnStateChanged;
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region События для связи с реальным компонентом AudioPlayer (Эти события вызываются сервисом)
|
||||||
|
/// <summary>Запрос на загрузку и воспроизведение трека.</summary>
|
||||||
|
event Func<string, string?, string?, Task>? OnLoadAndPlayRequested;
|
||||||
|
|
||||||
|
/// <summary>Запрос на воспроизведение (снять с паузы).</summary>
|
||||||
|
event Func<Task>? OnPlayRequested;
|
||||||
|
|
||||||
|
/// <summary>Запрос на паузу.</summary>
|
||||||
|
event Func<Task>? OnPauseRequested;
|
||||||
|
|
||||||
|
/// <summary>Запрос на остановку и выгрузку трека.</summary>
|
||||||
|
event Func<Task>? OnStopRequested;
|
||||||
|
|
||||||
|
/// <summary>Запрос на перемотку (процент 0–100).</summary>
|
||||||
|
event Func<double, Task>? OnSeekRequested;
|
||||||
|
|
||||||
|
/// <summary>Запрос на изменение громкости (0–100).</summary>
|
||||||
|
event Func<double, Task>? OnVolumeChangeRequested;
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Методы для обновления состояния из AudioPlayer (Вызываются компонентом AudioPlayer, когда реальный аудиоэлемент меняет своё состояние.)
|
||||||
|
/// <summary>Уведомить сервис о том, что трек начал или прекратил играть.</summary>
|
||||||
|
void SetPlayingState(bool isPlaying);
|
||||||
|
|
||||||
|
/// <summary>Установить ID текущего трека.</summary>
|
||||||
|
void SetCurrentTrack(string? trackId);
|
||||||
|
|
||||||
|
/// <summary>Обновить прогресс и отображаемое время.</summary>
|
||||||
|
void UpdateProgress(double progress, string currentTime, string totalTime);
|
||||||
|
|
||||||
|
/// <summary>Уведомить об окончании трека.</summary>
|
||||||
|
void NotifyTrackEnded();
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ public class PlayerStorage
|
|||||||
await _js.InvokeVoidAsync("localStorage.setItem", VolumeKey, volume);
|
await _js.InvokeVoidAsync("localStorage.setItem", VolumeKey, volume);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<double> GetVolumeAsync()
|
public async Task<double?> GetVolumeAsync()
|
||||||
{
|
{
|
||||||
var volume = await _js.InvokeAsync<string>("localStorage.getItem", VolumeKey);
|
var volume = await _js.InvokeAsync<string>("localStorage.getItem", VolumeKey);
|
||||||
|
|
||||||
@@ -25,6 +25,6 @@ public class PlayerStorage
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -14,3 +14,4 @@
|
|||||||
@using Microsoft.AspNetCore.Authorization
|
@using Microsoft.AspNetCore.Authorization
|
||||||
@using PlaylistShared.Shared
|
@using PlaylistShared.Shared
|
||||||
@using PlaylistShared.Pwa.Components
|
@using PlaylistShared.Pwa.Components
|
||||||
|
@using PlaylistShared.Pwa.Extensions
|
||||||
7
PlaylistShared.Shared/DTO/TrackInfoDto.cs
Normal file
7
PlaylistShared.Shared/DTO/TrackInfoDto.cs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
namespace PlaylistShared.Shared.DTO;
|
||||||
|
|
||||||
|
public class TrackInfoDto
|
||||||
|
{
|
||||||
|
public string Title { get; set; }
|
||||||
|
public string CoverUri { get; set; }
|
||||||
|
}
|
||||||
@@ -5,6 +5,6 @@ public class YandexTrack
|
|||||||
public string Id { get; set; } = "";
|
public string Id { get; set; } = "";
|
||||||
public string Title { get; set; } = "";
|
public string Title { get; set; } = "";
|
||||||
public List<string> Artists { get; set; } = new();
|
public List<string> Artists { get; set; } = new();
|
||||||
public int DurationMs { get; set; }
|
public long DurationMs { get; set; }
|
||||||
public string CoverUri { get; set; } = "";
|
public string CoverUri { get; set; } = "";
|
||||||
}
|
}
|
||||||
22
PlaylistShared.Shared/DTO/YandexTrackSearchResult.cs
Normal file
22
PlaylistShared.Shared/DTO/YandexTrackSearchResult.cs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace PlaylistShared.Shared.DTO;
|
||||||
|
|
||||||
|
/// <summary>Результат поиска трека в Яндекс.Музыке.</summary>
|
||||||
|
public class YandexTrackSearchResult
|
||||||
|
{
|
||||||
|
[JsonPropertyName("trackId")]
|
||||||
|
public string TrackId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("title")]
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("artists")]
|
||||||
|
public List<string> Artists { get; set; } = new();
|
||||||
|
|
||||||
|
[JsonPropertyName("coverUri")]
|
||||||
|
public string CoverUri { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("durationMs")]
|
||||||
|
public long DurationMs { get; set; }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user