Files
PlaylistShared/PlaylistShared.Pwa/Pages/SharedPlaylistView.razor

601 lines
25 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@page "/shared/{token}"
<PageTitle>@_playlist?.Title - Playlist Share</PageTitle>
@using PlaylistShared.Pwa.Components.Common
@using PlaylistShared.Pwa.Components.SharedPlaylist
@using PlaylistShared.Pwa.Components.SharedPlaylist.Cards
@using PlaylistShared.Shared.DTO
@using PlaylistShared.Shared.Enums
@using PlaylistShared.Pwa.Services
@using PlaylistShared.Shared.SharedPlaylist
@using PlaylistShared.Shared.Yandex
@inject HttpClient Http
@inject ISnackbar Snackbar
@inject NavigationManager Navigation
@inject AuthenticationStateProvider AuthProvider
@inject IDialogService DialogService
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pa-1" Style="height: 100%;">
@if (_loading)
{
<MudProgressCircular Indeterminate />
}
else if (_playlist == null)
{
<MudAlert Severity="Severity.Error">Плейлист не найден или у вас нет доступа</MudAlert>
}
else
{
@* --- ВЕРСИЯ ДЛЯ ПК (сетка) --- *@
<MudHidden Breakpoint="Breakpoint.MdAndUp" Invert>
<MudGrid Spacing="2" Class="flex-grow-1 pt-2" Style="height: 100%;">
<MudItem xs="12" md="6" Style="height: 100%;">
@PlaylistCardContent
</MudItem>
@if (_canAdd)
{
<MudItem xs="12" md="6" Style="height: 100%;">
@AddTrackCardContent
</MudItem>
}
</MudGrid>
</MudHidden>
@* --- ВЕРСИЯ ДЛЯ МОБИЛОК (вкладки внизу) --- *@
<MudHidden Breakpoint="Breakpoint.SmAndDown" Invert>
<div class="d-flex flex-column pa-0 ma-0" style="height: 100%; min-height: 0;">
@* Область контента: оба компонента здесь всегда *@
<div class="flex-grow-1 relative pa-0" style="min-height: 0;">
<div class="@(_activeMobileTab == 0 ? "d-flex" : "d-none") flex-column" style="height: 100%;">
<div class="flex-grow-1 overflow-auto pb-1">
@PlaylistCardContent
</div>
</div>
<div class="@(_activeMobileTab == 1 ? "d-flex" : "d-none") flex-column" style="height: 100%;">
<div class="flex-grow-1 overflow-auto pb-1">
@AddTrackCardContent
</div>
</div>
</div>
@* Кастомная панель навигации внизу *@
@if (_canAdd)
{
<MudPaper Elevation="0" Class="py-1">
<MudNavMenu Margin="Margin.None" Class="d-flex flex-row justify-space-around">
<MudIconButton Icon="@Icons.Material.Filled.LibraryMusic"
Color="@(_activeMobileTab == 0 ? Color.Primary : Color.Default)"
OnClick="() => _activeMobileTab = 0" />
<MudIconButton Icon="@Icons.Material.Filled.AddCircle"
Color="@(_activeMobileTab == 1 ? Color.Primary : Color.Default)"
OnClick="() => _activeMobileTab = 1" />
</MudNavMenu>
</MudPaper>
}
</div>
</MudHidden>
}
</MudContainer>
<style>
.horizontal-scroll-container {
display: flex;
overflow-x: auto;
gap: 12px;
padding-bottom: 8px;
scroll-snap-type: x mandatory;
-webkit-overflow-scrolling: touch;
}
.horizontal-scroll-container > div {
scroll-snap-align: start;
flex: 0 0 auto; /* Запрещает карточкам сжиматься */
}
/* Скрываем скроллбар для эстетики */
.horizontal-scroll-container::-webkit-scrollbar { display: none; }
.horizontal-scroll-container { -ms-overflow-style: none; scrollbar-width: none; }
</style>
@code {
/// <summary>Токен расшаренного плейлиста</summary>
[Parameter] public string Token { get; set; }
private RenderFragment PlaylistCardContent => __builder =>
{
<MudCard Elevation="0" Class="d-flex flex-column" Style="height: 100%;">
<MudCardHeader>
<CardHeaderContent>
<PlaylistHeader Playlist="@_playlist" />
</CardHeaderContent>
<CardHeaderActions>
<MudIconButton Icon="@Icons.Material.Filled.Refresh" OnClick="LoadTracks" Disabled="@_tracksLoading" />
</CardHeaderActions>
</MudCardHeader>
<MudCardContent Class="flex-grow-1 overflow-auto flex-column">
<MudTable Items="@_tracks" Virtualize Hover Elevation="0" Breakpoint="Breakpoint.None" Class="d-flex flex-grow-1 flex-column" Style="min-height: 0;">
<RowTemplate>
<MudTd Class="py-1 px-0" Style="width: 100%;">
<TrackItem Track="@context" PlaylistShareToken="@Token" CanPlay="@_canPlay" />
</MudTd>
@if (_canRemove)
{
<MudTd Class="py-1 px-0">
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" Size="Size.Small" OnClick="() => OnRemoveTrack(context)" />
</MudTd>
}
</RowTemplate>
</MudTable>
</MudCardContent>
</MudCard>
};
private RenderFragment AddTrackCardContent => __builder =>
{
<MudCard Class="d-flex flex-column" Elevation="0" Style="height: 100%; overflow: hidden;">
<MudCardHeader>
<MudStack>
<MudText Typo="Typo.h6" Color="Color.Primary" Class="mb-4">Добавление треков</MudText>
<MudTextField @bind-Value="_searchQuery"
@bind-Value:after="OnSearchQueryChanged"
Variant="Variant.Outlined"
FullWidth
Label="Название или ссылка на трек Яндекс.Музыки"
Disabled="@_isSearching"
Adornment="Adornment.End" AdornmentIcon="@Icons.Material.Filled.Search" AdornmentColor="Color.Secondary" />
<MudToggleGroup T="TrackSearchType"
@bind-Value="_searchType"
@bind-Value:after="OnSearchTypeChanged"
Size="Size.Small"
Color="Color.Primary"
Disabled="@(_isSearching)">
<MudToggleItem Value="TrackSearchType.All" Text="Все" />
<MudToggleItem Value="TrackSearchType.Track" Text="Треки" />
<MudToggleItem Value="TrackSearchType.Album" Text="Альбомы" />
<MudToggleItem Value="TrackSearchType.Playlist" Text="Плейлисты" />
<MudToggleItem Value="TrackSearchType.Artist" Text="Исполнители" />
</MudToggleGroup>
</MudStack>
</MudCardHeader>
<MudCardContent Class="flex-grow-1 overflow-auto flex-column">
@if (_isSearching)
{
<MudProgressCircular Indeterminate Class="mx-auto my-8" />
}
else if (_searchResult != null)
{
@* Секция исполнителей *@
@if (_searchResult?.Artists?.Any() == true)
{
<MudText Typo="Typo.h6" Class="mt-4 mb-2 ml-2">Исполнители</MudText>
<div class="horizontal-scroll-container px-2">
@foreach (var artist in _searchResult.Artists)
{
<div style="width: 70px;">
<ArtistCard Item="artist" OnClick="() => SearchTracksByEntity(artist.Id, artist.Name, TrackSearchType.Artist)" />
</div>
}
</div>
}
@* Секция альбомов *@
@if (_searchResult?.Albums?.Any() == true)
{
<MudText Typo="Typo.h6" Class="mt-4 mb-2 ml-2">Альбомы</MudText>
<div class="horizontal-scroll-container px-2">
@foreach (var album in _searchResult.Albums)
{
<div style="width: 70px;">
<AlbumCard Item="album" OnClick="() => SearchTracksByEntity(album.Id, album.Title, TrackSearchType.Album)" />
</div>
}
</div>
}
@* Секция плейлистов *@
@if (_searchResult?.Playlists?.Any() == true)
{
<MudText Typo="Typo.h6" Class="mt-4 mb-2 ml-2">Плейлисты</MudText>
<div class="horizontal-scroll-container px-2">
@foreach (var playlist in _searchResult.Playlists)
{
<div style="width: 70px;">
<PlaylistCard Item="playlist" OnClick="() => SearchTracksByEntity(playlist.Uuid, playlist.Title, TrackSearchType.Playlist)" />
</div>
}
</div>
}
@* Секция треков *@
@if (_searchResult?.Tracks != null)
{
<MudText Typo="Typo.h6" Class="mt-4 mb-2 ml-2">Треки</MudText>
<MudTable Items="@_searchResult.Tracks"
Hover
Elevation="0"
Class="d-flex flex-grow-1 flex-column"
Style="min-height: 0;"
Breakpoint="Breakpoint.Sm">
<RowTemplate>
<MudTd Class="pa-1" Style="width: 100%;">
<TrackItem Track="@context" PlaylistShareToken="@Token" />
</MudTd>
<MudTd Class="pa-1">
<MudToggleIconButton Toggled="@_existingTrackIds.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>
}
}
</MudCardContent>
</MudCard>
};
/// <summary>Активная вкладка для моб.версии (0 = плейлист, 1 = добавление треков)</summary>
private int _activeMobileTab = 0;
/// <summary>
/// Список ID треков, уже добавленных в плейлист.
/// Используется для быстрого определения, добавлен трек или нет, при отображении результатов поиска.
/// </summary>
private HashSet<string> _existingTrackIds = new();
/// <summary>
/// Список добавленных в плейлист треков.
/// </summary>
private List<YandexTrack> _tracks = new();
/// <summary>Свойства плейлиста.</summary>
private SharedPlaylistDto? _playlist;
/// <summary>Пользователь является создателем плейлиста.</summary>
private bool _isCreator;
/// <summary>Пользователь имеет право воспроизведения треков.</summary>
private bool _canPlay;
/// <summary>Пользователь имеет право добавления треков в плейлист.</summary>
private bool _canAdd;
/// <summary>Пользователь имеет право удаления треков из плейлиста.</summary>
private bool _canRemove;
/// <summary>Пользователь авторизован из под учетной записи.</summary>
private bool _isAuthenticated;
/// <summary>Текущий ID пользователя, если авторизован.</summary>
private string? _currentUserId;
/// <summary>Обновление прав доступа к плейлисту, если создатель.</summary>
private UpdatePermissionsDto _editPermissions = new();
/// <summary>Состояние: Происходит загрузка плейлиста.</summary>
private bool _loading = true;
/// <summary>Состояние: Происходит загрузка треков плейлиста.</summary>
private bool _tracksLoading;
/********************************
* Вкладка добавления треков
*********************************/
/// <summary>Строка запроса поиска.</summary>
private string _searchQuery = "";
/// <summary>Тип поиска.</summary>
private TrackSearchType _searchType = TrackSearchType.All;
/// <summary>Состояние: Происходит поиск.</summary>
private bool _isSearching = false;
/// <summary>Результат поиска.</summary>
private YandexSearchResult? _searchResult = null;
protected override async Task OnInitializedAsync()
{
var authState = await AuthProvider.GetAuthenticationStateAsync();
_isAuthenticated = authState.User.Identity?.IsAuthenticated == true;
_currentUserId = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
await LoadPlaylist();
await LoadTracks();
}
/// <summary>Установка разрешений.</summary>
private async Task ConfigurePermissions()
{
if (_playlist is null)
{
_isCreator = false;
_canAdd = false;
_canRemove = false;
_canPlay = false;
}
else
{
_isCreator = _playlist.CreatorUserId.ToString() == _currentUserId;
_canAdd = _isCreator
|| _playlist.AddPermission == EditPermission.Everyone
|| (_playlist.AddPermission == EditPermission.AuthorizedOnly && _isAuthenticated);
_canRemove = _isCreator
|| _playlist.RemovePermission == EditPermission.Everyone
|| (_playlist.RemovePermission == EditPermission.AuthorizedOnly && _isAuthenticated);
_canPlay = _isCreator
|| _playlist.PlayPermission == ViewPermission.Everyone
|| (_playlist.PlayPermission == ViewPermission.AuthorizedOnly && _isAuthenticated);
if (_isCreator && _isAuthenticated)
{
_editPermissions = new UpdatePermissionsDto
{
ViewPermission = _playlist.ViewPermission,
AddPermission = _playlist.AddPermission,
RemovePermission = _playlist.RemovePermission,
PlayPermission = _playlist.PlayPermission,
};
}
}
}
/// <summary>Загрузка плейлиста.</summary>
private async Task LoadPlaylist()
{
try
{
var response = await Http.GetFromJsonAsync<ApiResponse<SharedPlaylistDto>>($"/api/sharedplaylist/{Token}");
if (response?.Success == true)
{
_playlist = response.Data;
_activeMobileTab = 0;
await ConfigurePermissions();
}
else
{
Snackbar.Add(response?.Error?.Message ?? "Не удалось загрузить плейлист", Severity.Error);
}
}
catch (Exception ex)
{
Snackbar.Add($"Ошибка: {ex.Message}", Severity.Error);
}
finally
{
_loading = false;
StateHasChanged();
}
}
/// <summary>Загрузка треков.</summary>
private async Task LoadTracks()
{
if (_playlist == null) return;
_tracksLoading = true;
try
{
var response = await Http.GetFromJsonAsync<ApiResponse<YandexPlaylistData>>($"/api/sharedplaylist/{Token}/tracks");
if (response?.Success == true && response.Data != null)
{
_tracks = response.Data.Tracks;
await GenerateUniqTrackIds();
}
else
{
Snackbar.Add(response?.Error?.Message ?? "Не удалось загрузить треки", Severity.Error);
}
}
catch (Exception ex)
{
Snackbar.Add($"Ошибка загрузки треков: {ex.Message}", Severity.Error);
}
finally
{
_tracksLoading = false;
StateHasChanged();
}
}
private async Task GenerateUniqTrackIds()
{
_existingTrackIds = _tracks.Select(t => t.TrackId).ToHashSet();
}
/// <summary>Удаление трека из списка плейлиста</summary>
private async Task OnRemoveTrack(YandexTrack track)
{
var confirmed = await DialogService.ShowMessageBoxAsync(
"Подтверждение удаления",
$"Вы уверены, что хотите удалить трек \"{track.Title}\"?",
yesText: "Удалить", cancelText: "Отмена");
if (confirmed != true) return;
await RemoveTrack(track);
}
#region Добавление/удаление трека
/// <summary>Добавление трека.</summary>
private async Task AddTrack(YandexTrack track)
{
if (_existingTrackIds.Contains(track.TrackId)) return;
try
{
var request = new UpdateTrackListRequest { TrackIds = new List<string> { track.TrackId } };
var response = await Http.PostAsJsonAsync($"/api/sharedplaylist/{Token}/add-tracks", request);
if (response.IsSuccessStatusCode)
{
Snackbar.Add($"Трек \"{track.Title}\" добавлен", Severity.Success, c => c.SnackbarVariant = Variant.Outlined);
_tracks.Add(track);
await GenerateUniqTrackIds();
}
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);
}
StateHasChanged();
}
/// <summary>Удаление трека.</summary>
private async Task RemoveTrack(YandexTrack track)
{
if (!_existingTrackIds.Contains(track.TrackId)) return;
try
{
var request = new UpdateTrackListRequest { TrackIds = new List<string> { track.TrackId } };
var response = await Http.PostAsJsonAsync($"/api/sharedplaylist/{Token}/remove-tracks", request);
if (response.IsSuccessStatusCode)
{
Snackbar.Add("Трек удалён", Severity.Success);
_tracks.Remove(track);
await GenerateUniqTrackIds();
}
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);
}
StateHasChanged();
}
#endregion
#region Вкладка Добавление треков
private async Task OnSearchQueryChanged()
{
await SearchTracks(byId: false);
}
private async Task OnSearchTypeChanged()
{
await SearchTracks(byId: false);
}
/// <summary>
/// Поиск трека
/// </summary>
/// <param name="byId">По ID</param>
/// <param name="forcedQuery">Принудительный запрос</param>
/// <returns></returns>
private async Task SearchTracks(bool byId = false, string? forcedQuery = null)
{
var query = forcedQuery ?? _searchQuery;
if (string.IsNullOrWhiteSpace(query))
{
_searchResult = null;
return;
}
var type = _searchType;
// Распознавание ссылки Яндекс.Музыки
if (!byId && Uri.TryCreate(query, UriKind.Absolute, out var uri) && uri.Host == "music.yandex.ru")
{
try
{
(type, query) = ParseYandexMusicUrl(uri);
byId = true;
}
catch (Exception ex)
{
Snackbar.Add($"Ошибка распознавания URL: {ex.Message}", Severity.Error);
return;
}
}
_isSearching = true;
_searchResult = null;
StateHasChanged();
try
{
var url = $"/api/yandexsearch/search?query={Uri.EscapeDataString(query)}&searchType={Uri.EscapeDataString(type.ToString())}&limit=20";
if (byId)
url += "&byId=true";
if (!string.IsNullOrEmpty(Token))
url += $"&shared_id={Uri.EscapeDataString(Token)}";
var response = await Http.GetFromJsonAsync<ApiResponse<YandexSearchResult>>(url);
if (response?.Success == true)
{
_searchResult = response.Data ?? new YandexSearchResult();
}
else
{
Snackbar.Add(response?.Error?.Message ?? "Ошибка поиска", Severity.Error);
_searchResult = null;
}
}
catch (Exception ex)
{
Snackbar.Add($"Ошибка: {ex.Message}", Severity.Error);
_searchResult = null;
}
finally
{
_isSearching = false;
StateHasChanged();
}
}
/// <summary>Поиск по определенному типу результата.</summary>
private async Task SearchTracksByEntity(string entityId, string title, TrackSearchType entityType)
{
// Переключаем тип и ищем по ID
_searchType = entityType;
_searchQuery = title;
await SearchTracks(byId: true, forcedQuery: entityId);
}
/// <summary>Переключатель "Добавление/Удаление" трека</summary>
private async Task ToggleTrack(YandexTrack track)
{
if (_existingTrackIds.Contains(track.TrackId))
await RemoveTrack(track);
else
await AddTrack(track);
}
/// <summary>Парсинг ссылки на яндекс трек</summary>
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");
}
#endregion
}