Compare commits

...

3 Commits

30 changed files with 559 additions and 383 deletions

View File

@@ -5,7 +5,7 @@ using PlaylistShared.Api.Entities;
using PlaylistShared.Api.Extensions; using PlaylistShared.Api.Extensions;
using PlaylistShared.Api.Services; using PlaylistShared.Api.Services;
using PlaylistShared.Shared; using PlaylistShared.Shared;
using PlaylistShared.Shared.Shared; using PlaylistShared.Shared.SharedPlaylist;
namespace PlaylistShared.Api.Controllers; namespace PlaylistShared.Api.Controllers;

View File

@@ -7,7 +7,7 @@ using PlaylistShared.Api.Services;
using PlaylistShared.Shared; using PlaylistShared.Shared;
using PlaylistShared.Shared.Enums; using PlaylistShared.Shared.Enums;
using PlaylistShared.Shared.Playlist; using PlaylistShared.Shared.Playlist;
using PlaylistShared.Shared.Shared; using PlaylistShared.Shared.SharedPlaylist;
using YandexMusic; using YandexMusic;
namespace PlaylistShared.Api.Controllers; namespace PlaylistShared.Api.Controllers;

View File

@@ -6,7 +6,7 @@ using PlaylistShared.Api.Extensions;
using PlaylistShared.Api.Services; using PlaylistShared.Api.Services;
using PlaylistShared.Shared; using PlaylistShared.Shared;
using PlaylistShared.Shared.DTO; using PlaylistShared.Shared.DTO;
using PlaylistShared.Shared.Shared; using PlaylistShared.Shared.SharedPlaylist;
using YandexMusic.API.Models.Playlist; using YandexMusic.API.Models.Playlist;
[ApiController] [ApiController]
@@ -101,7 +101,7 @@ public class SharedPlaylistController : ControllerBase
// POST /api/sharedplaylist/{token}/add-tracks // POST /api/sharedplaylist/{token}/add-tracks
[HttpPost("{token}/add-tracks")] [HttpPost("{token}/add-tracks")]
public async Task<ActionResult<ApiResponse<object>>> AddTracks(string token, [FromBody] AddTracksRequest request) public async Task<ActionResult<ApiResponse<object>>> AddTracks(string token, [FromBody] UpdateTrackListRequest request)
{ {
var currentUserId = User.GetUserIdOrNull(); var currentUserId = User.GetUserIdOrNull();
var playlist = await _sharedService.GetEntityByTokenAsync(token); var playlist = await _sharedService.GetEntityByTokenAsync(token);
@@ -131,7 +131,7 @@ public class SharedPlaylistController : ControllerBase
// POST /api/sharedplaylist/{token}/remove-tracks // POST /api/sharedplaylist/{token}/remove-tracks
[HttpPost("{token}/remove-tracks")] [HttpPost("{token}/remove-tracks")]
public async Task<ActionResult<ApiResponse<object>>> RemoveTracks(string token, [FromBody] RemoveTracksRequest request) public async Task<ActionResult<ApiResponse<object>>> RemoveTracks(string token, [FromBody] UpdateTrackListRequest request)
{ {
var currentUserId = User.GetUserIdOrNull(); var currentUserId = User.GetUserIdOrNull();
var playlist = await _sharedService.GetEntityByTokenAsync(token); var playlist = await _sharedService.GetEntityByTokenAsync(token);
@@ -164,23 +164,6 @@ public class SharedPlaylistController : ControllerBase
return Ok(ApiResponse<object>.Ok(new { message = "Треки удалены" })); return Ok(ApiResponse<object>.Ok(new { message = "Треки удалены" }));
} }
// POST /api/sharedplaylist/{token}/add-track-by-link
[HttpPost("{token}/add-track-by-link")]
public async Task<ActionResult<ApiResponse<object>>> AddTrackByLink(string token, [FromBody] AddTrackByLinkRequest request)
{
var trackId = ExtractTrackIdFromLink(request.Link);
if (string.IsNullOrEmpty(trackId))
return BadRequest(ApiResponse<object>.Fail(new ErrorResponse { StatusCode = 400, Message = "Неверный формат ссылки" }));
return await AddTracks(token, new AddTracksRequest { TrackIds = new List<string> { trackId } });
}
private string? ExtractTrackIdFromLink(string link)
{
var match = System.Text.RegularExpressions.Regex.Match(link, @"/track/(\d+)");
return match.Success ? match.Groups[1].Value : null;
}
private YandexPlaylistData MapToYandexPlaylistData(YPlaylist playlist) private YandexPlaylistData MapToYandexPlaylistData(YPlaylist playlist)
{ {
return new YandexPlaylistData return new YandexPlaylistData

View File

@@ -6,6 +6,7 @@ using PlaylistShared.Api.Extensions;
using PlaylistShared.Api.Services; using PlaylistShared.Api.Services;
using PlaylistShared.Shared; using PlaylistShared.Shared;
using PlaylistShared.Shared.DTO; using PlaylistShared.Shared.DTO;
using PlaylistShared.Shared.Enums;
namespace PlaylistShared.Api.Controllers; namespace PlaylistShared.Api.Controllers;
@@ -26,9 +27,11 @@ public class YandexSearchController : ControllerBase
} }
[HttpGet("tracks")] [HttpGet("tracks")]
public async Task<ActionResult<ApiResponse<List<YandexTrack>>>> SearchTracks( public async Task<ActionResult<ApiResponse<List<YandexTrack>>>> SearchQuery(
[FromQuery] string query, [FromQuery] string query,
[FromQuery] int limit = 20, [FromQuery] int limit = 20,
[FromQuery] TrackSearchType? searchType = TrackSearchType.All,
[FromQuery] bool byId = false,
[FromQuery] string? shared_id = null) [FromQuery] string? shared_id = null)
{ {
if (string.IsNullOrWhiteSpace(query)) if (string.IsNullOrWhiteSpace(query))
@@ -68,7 +71,17 @@ public class YandexSearchController : ControllerBase
Message = "Токен Яндекс.Музыки не установлен или недействителен." Message = "Токен Яндекс.Музыки не установлен или недействителен."
})); }));
var results = await _yandexService.SearchTracksAsync(user, query, limit); List<YandexTrack>? results = null;
if (byId)
{
results = await _yandexService.SearchTracksByIdAsync(user, query, searchType.Value, limit);
}
else
{
results = await _yandexService.SearchTracksAsync(user, query, searchType, limit);
}
return Ok(ApiResponse<List<YandexTrack>>.Ok(results)); return Ok(ApiResponse<List<YandexTrack>>.Ok(results));
} }
} }

View File

@@ -1,7 +1,7 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using PlaylistShared.Api.Data; using PlaylistShared.Api.Data;
using PlaylistShared.Api.Entities; using PlaylistShared.Api.Entities;
using PlaylistShared.Shared.Shared; using PlaylistShared.Shared.SharedPlaylist;
namespace PlaylistShared.Api.Services; namespace PlaylistShared.Api.Services;

View File

@@ -4,7 +4,7 @@ using PlaylistShared.Api.Entities;
using PlaylistShared.Shared.Auth; using PlaylistShared.Shared.Auth;
using PlaylistShared.Shared.Enums; using PlaylistShared.Shared.Enums;
using PlaylistShared.Shared.Playlist; using PlaylistShared.Shared.Playlist;
using PlaylistShared.Shared.Shared; using PlaylistShared.Shared.SharedPlaylist;
namespace PlaylistShared.Api.Services; namespace PlaylistShared.Api.Services;

View File

@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.DataProtection;
using PlaylistShared.Api.Entities; using PlaylistShared.Api.Entities;
using PlaylistShared.Shared.DTO; using PlaylistShared.Shared.DTO;
using PlaylistShared.Shared.Enums;
using YandexMusic; using YandexMusic;
using YandexMusic.API.Extensions.API; using YandexMusic.API.Extensions.API;
using YandexMusic.API.Models.Playlist; using YandexMusic.API.Models.Playlist;
@@ -107,12 +108,26 @@ public class YandexMusicService
} }
} }
public async Task<List<YandexTrack>> SearchTracksAsync(ApplicationUser user, string query, int limit = 20) public async Task<List<YandexTrack>> SearchTracksAsync(
ApplicationUser user,
string query,
TrackSearchType? searchType = TrackSearchType.All,
int limit = 20
)
{ {
var client = await CreateClientAsync(user); var client = await CreateClientAsync(user);
if (client == null) return new List<YandexTrack>(); if (client == null) return new List<YandexTrack>();
var searchResult = await client.SearchAsync(query, YandexMusic.API.Models.Common.YSearchType.Track, page: 0, pageSize: limit); var ySerchType = searchType switch
{
TrackSearchType.Artist => YandexMusic.API.Models.Common.YSearchType.Artist,
TrackSearchType.Album => YandexMusic.API.Models.Common.YSearchType.Album,
TrackSearchType.Playlist => YandexMusic.API.Models.Common.YSearchType.Playlist,
TrackSearchType.Track => YandexMusic.API.Models.Common.YSearchType.Track,
_ => YandexMusic.API.Models.Common.YSearchType.All
};
var searchResult = await client.SearchAsync(query, ySerchType, page: 0, pageSize: limit);
if (searchResult?.Tracks?.Results == null) return new List<YandexTrack>(); if (searchResult?.Tracks?.Results == null) return new List<YandexTrack>();
return searchResult.Tracks.Results.Select(t => new YandexTrack return searchResult.Tracks.Results.Select(t => new YandexTrack
@@ -124,4 +139,48 @@ public class YandexMusicService
DurationMs = t.DurationMs, DurationMs = t.DurationMs,
}).ToList(); }).ToList();
} }
public async Task<List<YandexTrack>> SearchTracksByIdAsync(
ApplicationUser user,
string id,
TrackSearchType searchType,
int limit = 20
)
{
var client = await CreateClientAsync(user);
if (client == null) return new List<YandexTrack>();
var ySerchType = searchType switch
{
TrackSearchType.Artist => YandexMusic.API.Models.Common.YSearchType.Artist,
TrackSearchType.Album => YandexMusic.API.Models.Common.YSearchType.Album,
TrackSearchType.Playlist => YandexMusic.API.Models.Common.YSearchType.Playlist,
TrackSearchType.Track => YandexMusic.API.Models.Common.YSearchType.Track,
_ => YandexMusic.API.Models.Common.YSearchType.All
};
IEnumerable<YTrack> searchResult = searchType switch
{
TrackSearchType.Playlist => (await client.GetPlaylistAsync(id)).Tracks.Select(t => t.Track),
TrackSearchType.Track => (await client.GetTracksAsync([id])),
TrackSearchType.Album => (await client.GetAlbumAsync(id)).Volumes.SelectMany(t => t),
TrackSearchType.Artist => (await client.GetArtistAsync(id)).Albums.SelectMany(t => t.Volumes.SelectMany(v => v)),
_ => new List<YTrack>()
};
if (searchType != TrackSearchType.Track)
{
searchResult = searchResult.Distinct();
if (limit > 0) searchResult = searchResult.Take(limit);
}
return searchResult.Select(t => new YandexTrack
{
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();
}
} }

View File

@@ -15,7 +15,7 @@
Paper="true"> Paper="true">
<MudPaper Class="pa-4"> <MudPaper Class="pa-4">
<MudText Typo="Typo.body2" Class="mb-2">Ссылка для приглашения:</MudText> <MudText Typo="Typo.body2" Class="mb-2">Ссылка для приглашения:</MudText>
<div style="display: flex; gap: 8px; align-items: center;"> <MudStack Row Gap="2" AlignItems="AlignItems.Center">
<MudTextField @bind-Value="_shareUrl" <MudTextField @bind-Value="_shareUrl"
ReadOnly="true" ReadOnly="true"
Variant="Variant.Outlined" Variant="Variant.Outlined"
@@ -26,7 +26,7 @@
OnClick="CopyLink" OnClick="CopyLink"
Icon="@Icons.Material.Filled.ContentCopy"> Icon="@Icons.Material.Filled.ContentCopy">
</MudIconButton> </MudIconButton>
</div> </MudStack>
</MudPaper> </MudPaper>
</MudPopover> </MudPopover>
@@ -66,7 +66,6 @@
} }
_shareUrl = ShareUrl; _shareUrl = ShareUrl;
_popoverOpen = true; _popoverOpen = true;
await CopyLink();
} }
} }

View File

@@ -1,29 +1,23 @@
@using Microsoft.AspNetCore.Components.Web @using Microsoft.AspNetCore.Components.Web
@inject IAudioPlayerService AudioPlayerService @inject IAudioPlayerService AudioPlayerService
<div class="track-cover-container" <MudItem @onmouseenter="HandleMouseEnter"
@onmouseenter="HandleMouseEnter" @onmouseleave="HandleMouseLeave"
@onmouseleave="HandleMouseLeave" style="position: relative; display: inline-block; cursor: pointer; border-radius: 4px; overflow: hidden;">
style="position: relative; display: inline-block; cursor: pointer;">
<MudImage Src="@CoverUrl.FormatCoverUrl(Width, Height)" 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 || IsCurrentTrackPlaying) @if (_isHovered || IsCurrentTrackPlaying)
{ {
<div class="play-overlay" <MudItem 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; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; border-radius: 4px;">
background: rgba(0,0,0,0.6);
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;">
<MudIconButton Icon="@(IsCurrentTrackPlaying? 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" />
</div> </MudItem>
} }
</div> </MudItem>
@code { @code {
[Parameter] public string CoverUrl { get; set; } = string.Empty; [Parameter] public string CoverUrl { get; set; } = string.Empty;
@@ -56,9 +50,9 @@
else else
{ {
await AudioPlayerService.LoadAndPlayAsync( await AudioPlayerService.LoadAndPlayAsync(
trackId: TrackId, trackId: TrackId,
playlistShareToken: playlistShareToken, playlistShareToken: playlistShareToken,
title: TrackTitle, title: TrackTitle,
coverUrl: CoverUrl); coverUrl: CoverUrl);
} }
} }

View File

@@ -0,0 +1,34 @@
@using PlaylistShared.Shared.DTO
@using PlaylistShared.Pwa.Components.Common
@using PlaylistShared.Pwa.Extensions
<MudStack Row AlignItems="AlignItems.Center">
<!-- Обложка с фиксированной шириной -->
<MudItem>
<TrackCoverWithPlay CoverUrl="@Track.CoverUri"
TrackId="@Track.TrackId"
TrackTitle="@Track.Title"
PlaylistShareToken="@PlaylistShareToken"
Width="40" Height="40" />
</MudItem>
<!-- Информация о треке (занимает всё доступное место) -->
<MudItem>
<MudStack Spacing="0">
<MudText Typo="Typo.body1" Color="Color.Secondary">@Track.Title</MudText>
<MudText Typo="Typo.body2" >@string.Join(", ", Track.Artists)</MudText>
</MudStack>
</MudItem>
<MudSpacer />
<!-- Длительность (фиксированная ширина по содержимому) -->
<MudItem>
<MudText Typo="Typo.body2">@Track.DurationMs.FormatDuration()</MudText>
</MudItem>
</MudStack>
@code {
[Parameter] public YandexTrack Track { get; set; } = null!;
[Parameter] public string PlaylistShareToken { get; set; } = string.Empty;
}

View File

@@ -8,51 +8,36 @@
@inject HttpClient Http @inject HttpClient Http
<MudPaper Class="pa-4" Elevation="0" Width="100%" 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;"> <MudStack Row Gap="2" AlignItems="AlignItems.Center" Wrap="Wrap.Wrap">
<div style="display: flex; align-items: center; gap: 12px;"> <!-- Информация о треке -->
<MudStack Row Gap="2" AlignItems="AlignItems.Center">
@if (!string.IsNullOrEmpty(_currentTrackCoverUrl)) @if (!string.IsNullOrEmpty(_currentTrackCoverUrl))
{ {
<MudImage Src="@_currentTrackCoverUrl" Height="40" Width="40" Class="rounded" /> <MudImage Src="@_currentTrackCoverUrl" Height="40" Width="40" Class="rounded" />
} }
<MudText Typo="Typo.body1" Style="font-weight: 500;">@_currentTrackTitle</MudText> <MudText Typo="Typo.body1" Style="font-weight: 500;">@_currentTrackTitle</MudText>
</div> </MudStack>
<div style="display: flex; gap: 8px;"> <!-- Кнопки управления -->
<MudIconButton Icon="@(_isPlaying? Icons.Material.Filled.Pause : Icons.Material.Filled.PlayArrow)" <MudStack Row Gap="1">
Size="Size.Medium" <MudIconButton Icon="@(_isPlaying? Icons.Material.Filled.Pause : Icons.Material.Filled.PlayArrow)" Size="Size.Medium" Color="Color.Primary" OnClick="TogglePlayPause" />
Color="Color.Primary" <MudIconButton Icon="@Icons.Material.Filled.Stop" Size="Size.Medium" Color="Color.Default" OnClick="Stop" />
OnClick="TogglePlayPause" /> </MudStack>
<MudIconButton Icon="@Icons.Material.Filled.Stop"
Size="Size.Medium"
Color="Color.Default"
OnClick="Stop" />
</div>
<div style="flex-grow: 1; min-width: 150px;"> <!-- Ползунок прогресса -->
<MudSlider @bind-Value="_currentProgress" <MudItem Style="flex-grow: 1; min-width: 150px;">
@bind-Value:event="oninput" <MudSlider @bind-Value="_currentProgress" @bind-Value:event="oninput" Min="0" Max="100" Size="Size.Small" ValueChanged="@((double newValue) => SeekTo(newValue))" />
Min="0" </MudItem>
Max="100"
Size="Size.Small"
ValueChanged="@((double newValue) => SeekTo(newValue))" />
</div>
<div style="display: flex; align-items: center; gap: 12px;"> <!-- Время и громкость -->
<MudStack Row Gap="2" AlignItems="AlignItems.Center">
<MudText Typo="Typo.body2">@_currentTime / @_totalTime</MudText> <MudText Typo="Typo.body2">@_currentTime / @_totalTime</MudText>
<div style="display: flex; align-items: center; gap: 8px; width: 120px;"> <MudStack Row Gap="1" AlignItems="AlignItems.Center" Style="width: 120px;">
<MudIconButton Icon="@(_currentVolume == 0 ? Icons.Material.Filled.VolumeOff : Icons.Material.Filled.VolumeUp)" <MudIconButton Icon="@(_currentVolume == 0 ? Icons.Material.Filled.VolumeOff : Icons.Material.Filled.VolumeUp)" Size="Size.Small" Color="Color.Default" OnClick="ToggleMute" />
Size="Size.Small" <MudSlider @bind-Value="_currentVolume" @bind-Value:event="oninput" Min="0" Max="100" Size="Size.Small" ValueChanged="@((double newValue) => ChangeVolume(newValue))" />
Color="Color.Default" </MudStack>
OnClick="ToggleMute" /> </MudStack>
<MudSlider @bind-Value="_currentVolume" </MudStack>
@bind-Value:event="oninput"
Min="0"
Max="100"
Size="Size.Small"
ValueChanged="@((double newValue) => ChangeVolume(newValue))" />
</div>
</div>
</div>
</MudPaper> </MudPaper>
<audio id="@_audioId" style="display: none;"></audio> <audio id="@_audioId" style="display: none;"></audio>

View File

@@ -6,32 +6,35 @@
</MudText> </MudText>
<!-- Вертикальный список шагов --> <!-- Вертикальный список шагов -->
<div class="instruction-steps"> <MudStack Class="my-4">
<div class="step-item"> <MudStack Row AlignItems="AlignItems.Center">
<div class="step-number">1</div> <MudPaper Elevation="0" Style="width: 28px; height: 28px; background-color: var(--mud-palette-primary); color: white; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; font-weight: bold;">1</MudPaper>
<div class="step-content"> <MudText>
Перейдите по <MudLink Href="https://oauth.yandex.ru/authorize?response_type=token&client_id=23cabbbdc6cd418abb4b39c32c41195d" Target="_blank">ссылке</MudLink> Перейдите по <MudLink Href="https://oauth.yandex.ru/authorize?response_type=token&client_id=23cabbbdc6cd418abb4b39c32c41195d" Target="_blank">ссылке</MudLink>
</div> </MudText>
</div> </MudStack>
<div class="step-item">
<div class="step-number">2</div> <MudStack Row AlignItems="AlignItems.Center">
<div class="step-content"> <MudPaper Elevation="0" Style="width: 28px; height: 28px; background-color: var(--mud-palette-primary); color: white; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; font-weight: bold;">1</MudPaper>
<MudText>
Авторизуйтесь в Яндексе (если ещё не вошли) Авторизуйтесь в Яндексе (если ещё не вошли)
</div> </MudText>
</div> </MudStack>
<div class="step-item">
<div class="step-number">3</div> <MudStack Row AlignItems="AlignItems.Center">
<div class="step-content"> <MudPaper Elevation="0" Style="width: 28px; height: 28px; background-color: var(--mud-palette-primary); color: white; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; font-weight: bold;">1</MudPaper>
<MudText>
Нажмите «Разрешить» Нажмите «Разрешить»
</div> </MudText>
</div> </MudStack>
<div class="step-item">
<div class="step-number">4</div> <MudStack Row AlignItems="AlignItems.Center">
<div class="step-content"> <MudPaper Elevation="0" Style="width: 28px; height: 28px; background-color: var(--mud-palette-primary); color: white; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; font-weight: bold;">1</MudPaper>
<MudText>
Скопируйте <strong>access_token</strong> из адресной строки после перенаправления Скопируйте <strong>access_token</strong> из адресной строки после перенаправления
</div> </MudText>
</div> </MudStack>
</div> </MudStack>
<MudAlert Severity="Severity.Info" Class="mt-4"> <MudAlert Severity="Severity.Info" Class="mt-4">
Пример: <code>https://music.yandex.ru/#access_token=ВАШ_ТОКЕН&...</code> Пример: <code>https://music.yandex.ru/#access_token=ВАШ_ТОКЕН&...</code>

View File

@@ -1,126 +0,0 @@
@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;">
<div style="display: flex; gap: 8px; margin-bottom: 16px;">
<MudTextField @bind-Value="_searchQuery"
Label="Название трека или исполнитель"
Variant="Variant.Outlined"
FullWidth="true"
OnKeyUp="@(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"
TrackTitle="@track.Title"
PlaylistShareToken="@ShareToken"
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">@track.DurationMs.FormatDuration()</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 (!_isFirstSearch)
{
<MudAlert Severity="Severity.Info">Ничего не найдено. Попробуйте изменить запрос.</MudAlert>
}
</MudPaper>
@code {
[Parameter] public EventCallback<string> OnAddTrack { get; set; }
[Parameter] public string ShareToken { get; set; } = string.Empty;
private List<YandexTrack> _searchResults = new();
private bool _isSearching;
private bool _isFirstSearch = true;
private HashSet<string> _addingTrackIds = new();
private string _searchQuery = string.Empty;
private async Task SearchTracks()
{
if (string.IsNullOrWhiteSpace(_searchQuery))
return;
_isFirstSearch = false;
_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<YandexTrack>>>(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(YandexTrack 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();
}
}
}

View File

@@ -1,77 +1,191 @@
@using PlaylistShared.Shared.Shared @using PlaylistShared.Pwa.Components.Common
@using PlaylistShared.Shared.DTO
@using PlaylistShared.Shared.Enums
@using PlaylistShared.Shared.SharedPlaylist
@inject HttpClient Http @inject HttpClient Http
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
<MudPaper Class="mb-4" Elevation="0" Style="background-color: rgba(0,0,0,0.05); border-radius: 8px;"> <MudStack AlignItems="AlignItems.Stretch">
<MudTabs @bind-ActivePanelIndex="_activeTabIndex" Elevation="0" Style="border-bottom: 1px solid rgba(0,0,0,0.1);"> <MudTextField @bind-Value="_searchQuery"
<MudTabPanel Text="По ссылке" Style="padding: 16px;"> @bind-Value:after="SearchTracks"
<MudGrid> Variant="Variant.Outlined"
<MudItem xs="12" sm="10"> FullWidth
<MudTextField @bind-Value="_trackLink" Label="Ссылка на трек Яндекс.Музыки" Label="Название или ссылка на трек Яндекс.Музыки"
Variant="Variant.Outlined" FullWidth="true" Disabled="@_isSearching"
Placeholder="https://music.yandex.ru/album/2488464/track/21696942" Adornment="Adornment.End" AdornmentIcon="@Icons.Material.Filled.Search" AdornmentColor="Color.Secondary"
OnKeyUp="@(async (e) => { if (e.Key == "Enter") await AddTrackByLink(); })" /> />
</MudItem>
<MudItem xs="12" sm="2"> <MudToggleGroup T="TrackSearchType"
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="AddTrackByLink" @bind-Value="_searchType"
Disabled="_addingTrack" FullWidth="true" Style="height: 100%;"> @bind-Value:after="SearchTracks"
@if (_addingTrack) Size="Size.Small"
{ Color="Color.Primary"
<MudProgressCircular Size="Size.Small" Indeterminate /> Disabled="@(_isSearching)"
} >
else <MudToggleItem Value="TrackSearchType.All" Text="Все" />
{ <MudToggleItem Value="TrackSearchType.Track" Text="Трек" />
<MudText>Добавить</MudText> <MudToggleItem Value="TrackSearchType.Album" Text="Альбом" />
} <MudToggleItem Value="TrackSearchType.Artist" Text="Исполнитель" />
</MudButton> </MudToggleGroup>
</MudItem>
</MudGrid> <MudTable Items="@_searchResults"
</MudTabPanel> Virtualize="@true"
<MudTabPanel Text="Поиск" Style="padding: 16px;"> Height="400px"
<AddTrackBySearch OnAddTrack="AddTrackById" Hover="true"
ShareToken="@ShareToken" /> Breakpoint="Breakpoint.Sm"
</MudTabPanel> Loading="@_isSearching">
</MudTabs> <RowTemplate>
</MudPaper> <MudTd Style="width: 100%;">
<TrackItem Track="@context" PlaylistShareToken="@ShareToken" />
</MudTd>
<MudTd>
<MudToggleIconButton Toggled="_addingTrackIds.Contains(context.TrackId)"
Icon="@Icons.Material.Filled.AddCircle"
Color="@Color.Primary"
ToggledIcon="@Icons.Material.Filled.RemoveCircle"
ToggledColor="@Color.Error"
ToggledChanged="() => ToggleTrack(context)" />
</MudTd>
</RowTemplate>
</MudTable>
</MudStack>
@code { @code {
private int _activeTabIndex = 0;
private string _trackLink = "";
private bool _addingTrack = false;
[Parameter] public string ShareToken { get; set; } = string.Empty; [Parameter] public string ShareToken { get; set; } = string.Empty;
[Parameter] public EventCallback OnTrackAdded { get; set; } [Parameter] public EventCallback OnTrackAdded { get; set; }
[Parameter] public EventCallback OnTrackRemoved { get; set; }
private async Task AddTrackByLink() private string _searchQuery = "";
private bool _isSearching = false;
private bool _isFirstSearch = true;
private TrackSearchType _searchType = TrackSearchType.All;
private List<YandexTrack> _searchResults = new();
private HashSet<string> _addingTrackIds = new();
private async Task SearchTracks()
{ {
if (string.IsNullOrWhiteSpace(_trackLink)) if (string.IsNullOrWhiteSpace(_searchQuery))
{ {
Snackbar.Add("Введите ссылку на трек", Severity.Warning);
return; return;
} }
var trackId = ExtractTrackIdFromLink(_trackLink); var query = _searchQuery.Trim();
if (string.IsNullOrEmpty(trackId)) var type = _searchType;
bool byId = false;
if (Uri.TryCreate(_searchQuery, UriKind.Absolute, out var uri) && uri.Host == "music.yandex.ru")
{ {
Snackbar.Add("Неверный формат ссылки", Severity.Warning); try
return; {
(type, query) = ParseYandexMusicUrl(uri);
byId = true;
}
catch (Exception ex)
{
Snackbar.Add($"Ошибка распознавания URL: {ex.Message}", Severity.Error);
return;
}
} }
await AddTrackById(trackId); _isFirstSearch = false;
if (!_addingTrack) // если не было ошибки _isSearching = true;
_trackLink = ""; try
{
var url = $"/api/yandexsearch/tracks?query={Uri.EscapeDataString(query)}&type={Uri.EscapeDataString(type.ToString())}&limit=20";
if (!string.IsNullOrEmpty(ShareToken))
url += $"&shared_id={Uri.EscapeDataString(ShareToken)}";
if (byId)
url += $"&byId={byId}";
var response = await Http.GetFromJsonAsync<ApiResponse<List<YandexTrack>>>(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 SearchTracksByQuery(string query)
{
if (string.IsNullOrWhiteSpace(query))
return;
}
private async Task ToggleTrack(YandexTrack track)
{
if (_addingTrackIds.Contains(track.TrackId))
{
await RemoveTrack(track);
}
else
{
await AddTrack(track);
}
}
private async Task RemoveTrack(YandexTrack track)
{
if (!_addingTrackIds.Remove(track.TrackId)) return;
try
{
await RemoveTrackById(track.TrackId);
await OnTrackRemoved.InvokeAsync();
Snackbar.Add($"Трек \"{track.Title}\" удален", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"Ошибка добавления: {ex.Message}", Severity.Error);
_addingTrackIds.Add(track.TrackId);
}
finally
{
StateHasChanged();
}
}
private async Task AddTrack(YandexTrack track)
{
if (_addingTrackIds.Contains(track.TrackId)) return;
_addingTrackIds.Add(track.TrackId);
try
{
await AddTrackById(track.TrackId);
await OnTrackAdded.InvokeAsync();
Snackbar.Add($"Трек \"{track.Title}\" добавлен", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"Ошибка добавления: {ex.Message}", Severity.Error);
_addingTrackIds.Remove(track.TrackId);
}
finally
{
StateHasChanged();
}
} }
private async Task AddTrackById(string trackId) private async Task AddTrackById(string trackId)
{ {
_addingTrack = true;
try try
{ {
var request = new AddTracksRequest { TrackIds = new List<string> { trackId } }; var request = new UpdateTrackListRequest { TrackIds = new List<string> { trackId } };
var response = await Http.PostAsJsonAsync($"/api/sharedplaylist/{ShareToken}/add-tracks", request); var response = await Http.PostAsJsonAsync($"/api/sharedplaylist/{ShareToken}/add-tracks", request);
if (response.IsSuccessStatusCode) if (response.IsSuccessStatusCode)
{ {
Snackbar.Add("Трек успешно добавлен", Severity.Success);
await OnTrackAdded.InvokeAsync(); // уведомляем родителя, что список треков изменился await OnTrackAdded.InvokeAsync(); // уведомляем родителя, что список треков изменился
} }
else else
@@ -86,14 +200,57 @@
} }
finally finally
{ {
_addingTrack = false;
StateHasChanged(); StateHasChanged();
} }
} }
private string? ExtractTrackIdFromLink(string link) private async Task RemoveTrackById(string trackId)
{ {
var match = System.Text.RegularExpressions.Regex.Match(link, @"/track/(\d+)"); try
return match.Success ? match.Groups[1].Value : null; {
var request = new UpdateTrackListRequest { TrackIds = new List<string> { trackId } };
var response = await Http.PostAsJsonAsync($"/api/sharedplaylist/{ShareToken}/remove-tracks", request);
if (response.IsSuccessStatusCode)
{
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
{
StateHasChanged();
}
}
private static (TrackSearchType Type, string Id) ParseYandexMusicUrl(Uri uri)
{
var segments = uri.AbsolutePath.Split('/', StringSplitOptions.RemoveEmptyEntries);
var dataMap = segments
.Select((val, idx) => new { val, idx })
.GroupBy(x => x.idx / 2)
.ToDictionary(
g => g.First().val,
g => g.ElementAtOrDefault(1)?.val
);
if (dataMap.TryGetValue("track", out var trackId) && !string.IsNullOrEmpty(trackId))
return (TrackSearchType.Track, trackId);
if (dataMap.TryGetValue("album", out var albumId) && !string.IsNullOrEmpty(albumId))
return (TrackSearchType.Album, albumId);
if (dataMap.TryGetValue("playlist", out var playlistId) && !string.IsNullOrEmpty(playlistId))
return (TrackSearchType.Playlist, playlistId);
if (dataMap.TryGetValue("artist", out var artistId) && !string.IsNullOrEmpty(artistId))
return (TrackSearchType.Artist, artistId);
throw new ArgumentException("Unsupported URL pattern");
} }
} }

View File

@@ -1,5 +1,5 @@
@using PlaylistShared.Shared.Enums @using PlaylistShared.Shared.Enums
@using PlaylistShared.Shared.Shared @using PlaylistShared.Shared.SharedPlaylist
@inject HttpClient Http @inject HttpClient Http
@inject ISnackbar Snackbar @inject ISnackbar Snackbar

View File

@@ -1,20 +1,20 @@
@using PlaylistShared.Pwa.Components.Common @using PlaylistShared.Pwa.Components.Common
@using PlaylistShared.Shared.Shared
@using PlaylistShared.Shared.Enums @using PlaylistShared.Shared.Enums
@using System.Security.Claims @using System.Security.Claims
@using PlaylistShared.Shared.SharedPlaylist
@inject HttpClient Http @inject HttpClient Http
@inject NavigationManager Navigation @inject NavigationManager Navigation
@inject AuthenticationStateProvider AuthProvider @inject AuthenticationStateProvider AuthProvider
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
@inject IDialogService DialogService @inject IDialogService DialogService
<div style="display: flex; gap: 16px; align-items: center;"> <MudStack Row AlignItems="AlignItems.Center">
@if (!string.IsNullOrEmpty(Playlist?.CoverUrl)) @if (!string.IsNullOrEmpty(Playlist?.CoverUrl))
{ {
<MudImage Src="@Playlist.CoverUrl.FormatCoverUrl(80, 80)" Height="80" Width="80" Class="rounded" /> <MudImage Src="@Playlist.CoverUrl.FormatCoverUrl(80, 80)" Height="80" Width="80" Class="rounded" />
} }
<div> <MudStack>
<div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap;"> <MudStack Row AlignItems="AlignItems.Center" Wrap="Wrap.Wrap">
<MudLink Href="@($"https://music.yandex.ru/playlists/{Playlist?.YandexPlaylistUuid}")" <MudLink Href="@($"https://music.yandex.ru/playlists/{Playlist?.YandexPlaylistUuid}")"
Typo="Typo.h5" Typo="Typo.h5"
Target="_blank" Target="_blank"
@@ -39,10 +39,10 @@
Title="Настройки доступа" Title="Настройки доступа"
Size="Size.Medium" /> Size="Size.Medium" />
} }
</div> </MudStack>
<MudText Typo="Typo.body2" Color="Color.Secondary">Владелец: @Playlist?.Creator?.UserName</MudText> <MudText Typo="Typo.body2" Color="Color.Secondary">Владелец: @Playlist?.Creator?.UserName</MudText>
</div> </MudStack>
</div> </MudStack>
@code { @code {
[Parameter] public SharedPlaylistDto? Playlist { get; set; } [Parameter] public SharedPlaylistDto? Playlist { get; set; }

View File

@@ -1,52 +1,18 @@
@using PlaylistShared.Pwa.Components.Common @using PlaylistShared.Pwa.Components.Common
@using PlaylistShared.Shared.DTO @using PlaylistShared.Shared.DTO
@using PlaylistShared.Shared.Shared @using PlaylistShared.Shared.SharedPlaylist
@inject HttpClient Http @inject HttpClient Http
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
@inject IDialogService DialogService @inject IDialogService DialogService
<MudTable Items="@_tracks" Hover="true" Breakpoint="Breakpoint.Sm" Loading="_loading"> <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> <RowTemplate>
<MudTd DataLabel="#" Style="font-weight: normal;">@context.Index</MudTd> <MudTd Style="width: 100%;">
<MudTd DataLabel="Обложка"> <TrackItem Track="@context" PlaylistShareToken="@ShareToken" />
@if (!string.IsNullOrEmpty(context.CoverUri))
{
@if (CanPlay)
{
<TrackCoverWithPlay CoverUrl="@context.CoverUri"
TrackId="@context.TrackId"
TrackTitle="@context.Title"
PlaylistShareToken="@ShareToken"
Width="50" Height="50"/>
}
else
{
<MudImage Src="@context.CoverUri.FormatCoverUrl(50, 50)" Height="50" Width="50" Class="rounded" />
}
}
</MudTd> </MudTd>
<MudTd DataLabel="Название">
<MudLink Href="@($"https://music.yandex.ru/track/{context.TrackId}")" 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="Длительность">@context.DurationMs.FormatDuration()</MudTd>
@if (CanRemove) @if (CanRemove)
{ {
<MudTd DataLabel=""> <MudTd>
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" OnClick="() => RemoveTrack(context)" /> <MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" OnClick="() => RemoveTrack(context)" />
</MudTd> </MudTd>
} }
@@ -57,7 +23,6 @@
[Parameter] public string ShareToken { get; set; } = string.Empty; [Parameter] public string ShareToken { get; set; } = string.Empty;
[Parameter] public bool CanPlay { get; set; } [Parameter] public bool CanPlay { get; set; }
[Parameter] public bool CanRemove { get; set; } [Parameter] public bool CanRemove { get; set; }
[Parameter] public string? CurrentPlayingTrackId { get; set; }
[Parameter] public bool IsPlaying { get; set; } [Parameter] public bool IsPlaying { get; set; }
[Parameter] public EventCallback<string> OnPlayTrack { get; set; } [Parameter] public EventCallback<string> OnPlayTrack { get; set; }
@@ -119,7 +84,7 @@
try try
{ {
var request = new RemoveTracksRequest { TrackIds = new List<string> { track.TrackId } }; var request = new UpdateTrackListRequest { TrackIds = new List<string> { track.TrackId } };
var response = await Http.PostAsJsonAsync($"/api/sharedplaylist/{ShareToken}/remove-tracks", request); var response = await Http.PostAsJsonAsync($"/api/sharedplaylist/{ShareToken}/remove-tracks", request);
if (response.IsSuccessStatusCode) if (response.IsSuccessStatusCode)
{ {

View File

@@ -1,5 +1,5 @@
@page "/favorites" @page "/favorites"
@using PlaylistShared.Shared.Shared @using PlaylistShared.Shared.SharedPlaylist
@attribute [Authorize] @attribute [Authorize]
@inject HttpClient Http @inject HttpClient Http
@inject ISnackbar Snackbar @inject ISnackbar Snackbar

View File

@@ -25,7 +25,7 @@
<!-- Локальная форма входа --> <!-- Локальная форма входа -->
<MudTextField @bind-Value="_loginModel.Username" Label="Имя пользователя" Variant="Variant.Outlined" FullWidth="true" Class="mb-3" /> <MudTextField @bind-Value="_loginModel.Username" Label="Имя пользователя" Variant="Variant.Outlined" FullWidth="true" Class="mb-3" />
<MudTextField @bind-Value="_loginModel.Password" Label="Пароль" Variant="Variant.Outlined" FullWidth="true" InputType="InputType.Password" OnKeyUp="@(async (e) => { if (e.Key == "Enter") await LocalLogin(); })" /> <MudTextField @bind-Value="_loginModel.Password" Label="Пароль" Variant="Variant.Outlined" FullWidth="true" InputType="InputType.Password" @bind-Value:after="LocalLogin" />
<MudButton Variant="Variant.Outlined" Color="Color.Secondary" OnClick="LocalLogin" FullWidth="true" Class="mt-4"> <MudButton Variant="Variant.Outlined" Color="Color.Secondary" OnClick="LocalLogin" FullWidth="true" Class="mt-4">
Войти (локально) Войти (локально)

View File

@@ -1,5 +1,6 @@
@page "/profile" @page "/profile"
@using Microsoft.AspNetCore.Authorization @using Microsoft.AspNetCore.Authorization
@using PlaylistShared.Pwa.Components.Profile
@using PlaylistShared.Shared.DTO @using PlaylistShared.Shared.DTO
@attribute [Authorize] @attribute [Authorize]
@inject HttpClient Http @inject HttpClient Http
@@ -13,7 +14,7 @@
</CardHeaderContent> </CardHeaderContent>
</MudCardHeader> </MudCardHeader>
<MudCardContent> <MudCardContent>
<div style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px; margin-bottom: 16px;"> <MudStack Row Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center" Class="mb-4">
<MudText Typo="Typo.body2"> <MudText Typo="Typo.body2">
Здесь вы можете указать токен доступа к Яндекс.Музыке. Здесь вы можете указать токен доступа к Яндекс.Музыке.
</MudText> </MudText>
@@ -21,7 +22,7 @@
Color="Color.Info" Color="Color.Info"
OnClick="() => _instructionDrawerOpen = true" OnClick="() => _instructionDrawerOpen = true"
Title="Как получить токен?" /> Title="Как получить токен?" />
</div> </MudStack>
<MudTextField @bind-Value="_token" Label="Токен Яндекс.Музыки" Variant="Variant.Outlined" FullWidth="true" /> <MudTextField @bind-Value="_token" Label="Токен Яндекс.Музыки" Variant="Variant.Outlined" FullWidth="true" />

View File

@@ -3,14 +3,14 @@
@using PlaylistShared.Shared.DTO @using PlaylistShared.Shared.DTO
@using PlaylistShared.Shared.Enums @using PlaylistShared.Shared.Enums
@using PlaylistShared.Pwa.Services @using PlaylistShared.Pwa.Services
@using PlaylistShared.Shared.Shared @using PlaylistShared.Shared.SharedPlaylist
@inject HttpClient Http @inject HttpClient Http
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
@inject NavigationManager Navigation @inject NavigationManager Navigation
@inject AuthenticationStateProvider AuthProvider @inject AuthenticationStateProvider AuthProvider
@inject IDialogService DialogService @inject IDialogService DialogService
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8"> <MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="mt-8">
@if (_loading) @if (_loading)
{ {
<MudProgressCircular Indeterminate /> <MudProgressCircular Indeterminate />
@@ -21,36 +21,42 @@
} }
else else
{ {
<MudCard> <MudSplitPanel>
<!-- Заголовок с обложкой --> <FirstPanel>
<MudCardHeader> <MudCard>
<CardHeaderContent> <!-- Заголовок с обложкой -->
<PlaylistHeader Playlist="@_playlist" /> <MudCardHeader>
</CardHeaderContent> <CardHeaderContent>
</MudCardHeader> <PlaylistHeader Playlist="@_playlist" />
</CardHeaderContent>
</MudCardHeader>
<MudCardContent> <MudCardContent>
<MudIconButton Icon="@Icons.Material.Filled.Refresh" OnClick="LoadTracks" Disabled="_tracksLoading" Size="Size.Medium" />
<TracksTable @ref="_tracksTableRef"
ShareToken="@Token"
CanPlay="@_canPlay"
CanRemove="@_canRemove"
/>
</MudCardContent>
</MudCard>
</FirstPanel>
<SecondPanel>
@if (_canAdd) @if (_canAdd)
{ {
<AddTrackSection ShareToken="@Token" <MudCard>
OnTrackAdded="LoadTracks" <MudCardHeader>
/> <CardHeaderContent>
<MudText Typo="Typo.h5" Color="Color.Primary">Добавление треков</MudText>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent>
<AddTrackSection ShareToken="@Token" OnTrackAdded="LoadTracks" OnTrackRemoved="LoadTracks" />
</MudCardContent>
</MudCard>
} }
</SecondPanel>
<!-- Список треков --> </MudSplitPanel>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
<MudText Typo="Typo.h6">Треки</MudText>
<MudIconButton Icon="@Icons.Material.Filled.Refresh" OnClick="LoadTracks" Disabled="_tracksLoading" Size="Size.Medium" />
</div>
<TracksTable @ref="_tracksTableRef"
ShareToken="@Token"
CanPlay="@_canPlay"
CanRemove="@_canRemove"
CurrentPlayingTrackId="_currentTrackId"
/>
</MudCardContent>
</MudCard>
} }
</MudContainer> </MudContainer>
@@ -58,8 +64,6 @@
@code { @code {
[Parameter] public string Token { get; set; } [Parameter] public string Token { get; set; }
private int _addTrackTabIndex = 0; // 0 - ссылка, 1 - поиск
private TracksTable? _tracksTableRef; private TracksTable? _tracksTableRef;
private SharedPlaylistDto? _playlist; private SharedPlaylistDto? _playlist;

View File

@@ -0,0 +1,101 @@
using PlaylistShared.Shared;
using PlaylistShared.Shared.SharedPlaylist;
using System.Net.Http.Json;
namespace PlaylistShared.Pwa.Services.Api;
public class SharedPlaylistClient
{
private readonly HttpClient _http;
public SharedPlaylistClient(HttpClient http)
{
_http = http;
}
/// <summary>
/// GET /api/sharedplaylist/{token}
/// </summary>
public async Task<ApiResponse<SharedPlaylistDto>> GetAsync(string token)
{
var response = await _http.GetFromJsonAsync<ApiResponse<SharedPlaylistDto>>($"/api/sharedplaylist/{token}");
return response ?? ApiResponse<SharedPlaylistDto>.Fail(new ErrorResponse { Message = "Invalid response" });
}
/// <summary>
/// GET /api/sharedplaylist/{token}/tracks
/// </summary>
public async Task<ApiResponse<YandexPlaylistData>> GetTracksAsync(string token)
{
var response = await _http.GetFromJsonAsync<ApiResponse<YandexPlaylistData>>($"/api/sharedplaylist/{token}/tracks");
return response ?? ApiResponse<YandexPlaylistData>.Fail(new ErrorResponse { Message = "Invalid response" });
}
/// <summary>
/// PUT /api/sharedplaylist/{token}/permissions
/// </summary>
public async Task<ApiResponse<SharedPlaylistDto>> UpdatePermissionsAsync(string token, UpdatePermissionsDto permissions)
{
var response = await _http.PutAsJsonAsync($"/api/sharedplaylist/{token}/permissions", permissions);
if (response.IsSuccessStatusCode)
{
var result = await response.Content.ReadFromJsonAsync<ApiResponse<SharedPlaylistDto>>();
return result ?? ApiResponse<SharedPlaylistDto>.Fail(new ErrorResponse { Message = "Invalid response" });
}
else
{
var error = await response.Content.ReadFromJsonAsync<ApiResponse<SharedPlaylistDto>>();
return error ?? ApiResponse<SharedPlaylistDto>.Fail(new ErrorResponse
{
StatusCode = (int)response.StatusCode,
Message = response.ReasonPhrase ?? "Unknown error"
});
}
}
/// <summary>
/// POST /api/sharedplaylist/{token}/add-tracks
/// </summary>
public async Task<ApiResponse<object>> AddTracksAsync(string token, List<string> trackIds)
{
var request = new UpdateTrackListRequest { TrackIds = trackIds };
var response = await _http.PostAsJsonAsync($"/api/sharedplaylist/{token}/add-tracks", request);
if (response.IsSuccessStatusCode)
{
var result = await response.Content.ReadFromJsonAsync<ApiResponse<object>>();
return result ?? ApiResponse<object>.Fail(new ErrorResponse { Message = "Invalid response" });
}
else
{
var error = await response.Content.ReadFromJsonAsync<ApiResponse<object>>();
return error ?? ApiResponse<object>.Fail(new ErrorResponse
{
StatusCode = (int)response.StatusCode,
Message = response.ReasonPhrase ?? "Unknown error"
});
}
}
/// <summary>
/// POST /api/sharedplaylist/{token}/remove-tracks
/// </summary>
public async Task<ApiResponse<object>> RemoveTracksAsync(string token, List<string> trackIds)
{
var request = new UpdateTrackListRequest { TrackIds = trackIds };
var response = await _http.PostAsJsonAsync($"/api/sharedplaylist/{token}/remove-tracks", request);
if (response.IsSuccessStatusCode)
{
var result = await response.Content.ReadFromJsonAsync<ApiResponse<object>>();
return result ?? ApiResponse<object>.Fail(new ErrorResponse { Message = "Invalid response" });
}
else
{
var error = await response.Content.ReadFromJsonAsync<ApiResponse<object>>();
return error ?? ApiResponse<object>.Fail(new ErrorResponse
{
StatusCode = (int)response.StatusCode,
Message = response.ReasonPhrase ?? "Unknown error"
});
}
}
}

View File

@@ -1,9 +0,0 @@
using System.Text.Json.Serialization;
namespace PlaylistShared.Shared.DTO;
public class RemoveTracksRequest
{
[JsonPropertyName("trackIds")]
public List<string> TrackIds { get; set; } = new();
}

View File

@@ -14,4 +14,4 @@ public enum EditPermission
/// <summary>Только тот пользователь, который добавил трек (актуально для удаления).</summary> /// <summary>Только тот пользователь, который добавил трек (актуально для удаления).</summary>
AddedByUserOnly, AddedByUserOnly,
} }

View File

@@ -0,0 +1,13 @@
namespace PlaylistShared.Shared.Enums;
/// <summary>
/// Типы поиска треков в Яндекс.Музыке, которые можно указать при поисковом запросе.
/// </summary>
public enum TrackSearchType
{
All,
Artist,
Album,
Playlist,
Track,
}

View File

@@ -1,4 +1,4 @@
namespace PlaylistShared.Shared.Shared; namespace PlaylistShared.Shared.SharedPlaylist;
public class AddTrackByLinkRequest public class AddTrackByLinkRequest
{ {

View File

@@ -2,7 +2,7 @@
using PlaylistShared.Shared.Enums; using PlaylistShared.Shared.Enums;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
namespace PlaylistShared.Shared.Shared; namespace PlaylistShared.Shared.SharedPlaylist;
/// <summary>DTO шеринг-плейлиста (без навигационных свойств).</summary> /// <summary>DTO шеринг-плейлиста (без навигационных свойств).</summary>
public class SharedPlaylistDto public class SharedPlaylistDto

View File

@@ -1,7 +1,7 @@
using PlaylistShared.Shared.Enums; using PlaylistShared.Shared.Enums;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
namespace PlaylistShared.Shared.Shared; namespace PlaylistShared.Shared.SharedPlaylist;
/// <summary>Запрос на обновление прав доступа шеринг-плейлиста.</summary> /// <summary>Запрос на обновление прав доступа шеринг-плейлиста.</summary>
public class UpdatePermissionsDto public class UpdatePermissionsDto

View File

@@ -1,8 +1,8 @@
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
namespace PlaylistShared.Shared.Shared; namespace PlaylistShared.Shared.SharedPlaylist;
public class AddTracksRequest public class UpdateTrackListRequest
{ {
[JsonPropertyName("trackIds")] [JsonPropertyName("trackIds")]
public List<string> TrackIds { get; set; } = new(); public List<string> TrackIds { get; set; } = new();

View File

@@ -1,6 +1,6 @@
using PlaylistShared.Shared.DTO; using PlaylistShared.Shared.DTO;
namespace PlaylistShared.Shared.Shared; namespace PlaylistShared.Shared.SharedPlaylist;
public class YandexPlaylistData public class YandexPlaylistData
{ {