Compare commits
27 Commits
58f21da19c
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38af6174fa | ||
|
|
2fe20c804a | ||
|
|
3c83a83396 | ||
|
|
14fcd7dff9 | ||
|
|
ecb12a7d4a | ||
|
|
2cd80c8082 | ||
|
|
78808ea525 | ||
|
|
d6da8460cc | ||
|
|
362762a813 | ||
|
|
7c05940dbf | ||
|
|
b3f19045fa | ||
|
|
b1febfc9dc | ||
|
|
0f2755281e | ||
|
|
d17ed30175 | ||
|
|
0f9dd1a8d8 | ||
|
|
45b8a168a1 | ||
|
|
c32eee0954 | ||
|
|
e2e117a539 | ||
|
|
64cc0e68a1 | ||
|
|
d2df57ca6e | ||
|
|
832363df57 | ||
|
|
d1e3e23e93 | ||
|
|
1c32b2e997 | ||
|
|
f9bbd895c4 | ||
|
|
8a809c9e7d | ||
|
|
e0c6b4119c | ||
|
|
eb323e874f |
@@ -42,7 +42,7 @@ public class AudioController : ControllerBase
|
||||
public async Task<IActionResult> StreamTrack(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 || user.YandexAccessToken is null) user = await GetUserFromSharedPlaylistId(shared_id);
|
||||
if (user == null) return Unauthorized();
|
||||
|
||||
var streamUrl = await _yandexService.GetTrackFileUrlAsync(user, trackId);
|
||||
@@ -78,7 +78,7 @@ public class AudioController : ControllerBase
|
||||
public async Task<ActionResult<ApiResponse<YandexTrack>>> 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 || user.YandexAccessToken is null) user = await GetUserFromSharedPlaylistId(shared_id);
|
||||
if (user == null) return Unauthorized();
|
||||
|
||||
var track = await _yandexService.GetYTrackAsync(user, trackId);
|
||||
|
||||
@@ -28,13 +28,13 @@ public class YandexSearchController : ControllerBase
|
||||
|
||||
[HttpGet("search")]
|
||||
public async Task<ActionResult<ApiResponse<YandexSearchResult>>> SearchQuery(
|
||||
[FromQuery] string query,
|
||||
[FromQuery] int limit = 20,
|
||||
[FromQuery] string query = "",
|
||||
[FromQuery] int limit = 40,
|
||||
[FromQuery] TrackSearchType searchType = TrackSearchType.All,
|
||||
[FromQuery] bool byId = false,
|
||||
[FromQuery] string? shared_id = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
if (string.IsNullOrWhiteSpace(query) && searchType != TrackSearchType.MyPlaylists)
|
||||
return BadRequest(ApiResponse<YandexSearchResult>.Fail(new ErrorResponse
|
||||
{
|
||||
StatusCode = 400,
|
||||
@@ -46,6 +46,8 @@ public class YandexSearchController : ControllerBase
|
||||
if (userId.HasValue)
|
||||
user = await _userManager.FindByIdAsync(userId.Value.ToString());
|
||||
|
||||
var byShareId = false;
|
||||
|
||||
// Если нет пользователя или у него нет токена, пробуем через shared_id
|
||||
if (user == null || string.IsNullOrEmpty(user.YandexAccessToken))
|
||||
{
|
||||
@@ -61,6 +63,8 @@ public class YandexSearchController : ControllerBase
|
||||
var owner = await _userManager.FindByIdAsync(playlist.CreatorUserId.ToString());
|
||||
if (owner == null) return StatusCode(500, "Не удалось найти владельца плейлиста.");
|
||||
user = owner;
|
||||
|
||||
byShareId = true;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(user.YandexAccessToken))
|
||||
@@ -74,7 +78,16 @@ public class YandexSearchController : ControllerBase
|
||||
|
||||
if (byId)
|
||||
{
|
||||
results = await _yandexService.SearchTracksByIdAsync(user, query, searchType, limit);
|
||||
results = await _yandexService.SearchTracksByIdAsync(user, query, searchType);
|
||||
}
|
||||
else if (searchType == TrackSearchType.MyPlaylists)
|
||||
{
|
||||
if (byShareId)
|
||||
{
|
||||
return Unauthorized("Необходимо подключение профиля к яндекс музыке.");
|
||||
}
|
||||
|
||||
results = await _yandexService.SearchMyPlaylists(user);
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -10,24 +10,24 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.6" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.6" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" Version="10.0.6" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.6" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.UI" Version="10.0.6" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.6" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.UI" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.7" />
|
||||
<PackageReference Include="AspNet.Security.OAuth.Yandex" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.6">
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.6" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.SqlServer" Version="10.0.6" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.SqlServer" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.23.0" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore.Swagger" Version="10.1.7" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.1.7" />
|
||||
<PackageReference Include="YandexMusic" Version="0.0.15" />
|
||||
<PackageReference Include="YandexMusic" Version="0.0.16" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -106,11 +106,11 @@ public class YandexMusicService
|
||||
};
|
||||
|
||||
var searchResult = await Api.Search.SearchAsync(query, ySerchType, page: 0, pageSize: limit);
|
||||
if (searchResult?.Tracks?.Results == null) return new YandexSearchResult();
|
||||
if (searchResult == null) return new YandexSearchResult();
|
||||
|
||||
return new YandexSearchResult
|
||||
{
|
||||
Tracks = searchResult.Tracks.Results.Select(t => new YandexTrack
|
||||
Tracks = searchResult.Tracks?.Results.Select(t => new YandexTrack
|
||||
{
|
||||
TrackId = t.Id,
|
||||
Title = t.Title,
|
||||
@@ -161,11 +161,56 @@ public class YandexMusicService
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<YandexSearchResult> SearchMyPlaylists(ApplicationUser user)
|
||||
{
|
||||
YandexSearchResult result = new();
|
||||
|
||||
await AuthorizeIfNot(user);
|
||||
|
||||
|
||||
var favoritesPlaylist = await Api.Playlist.FavoritesAsync();
|
||||
result.Playlists = favoritesPlaylist?.Select(t => new YandexPlaylist
|
||||
{
|
||||
Uuid = t.PlaylistUuid,
|
||||
Kind = t.Kind,
|
||||
OwnerUid = t.Owner?.Uid ?? string.Empty,
|
||||
Title = t.Title,
|
||||
Description = t.Description,
|
||||
CoverUrl = t.Cover.GetUrl(),
|
||||
TrackCount = t.TrackCount,
|
||||
}).ToList();
|
||||
|
||||
var personalPlaylists = await Api.Playlist.GetPersonalPlaylistsAsync();
|
||||
result.PersonalPlaylists = personalPlaylists?.Select(t => new YandexPlaylist
|
||||
{
|
||||
Uuid = t.PlaylistUuid,
|
||||
Kind = t.Kind,
|
||||
OwnerUid = t.Owner?.Uid ?? string.Empty,
|
||||
Title = t.Title,
|
||||
Description = t.Description,
|
||||
CoverUrl = t.Cover.GetUrl(),
|
||||
TrackCount = t.TrackCount,
|
||||
}).ToList();
|
||||
|
||||
var likedPlaylists = (await Api.Library.GetLikedPlaylistsAsync())?.Select(t => t.Playlist).ToList();
|
||||
result.LikedPlaylists = likedPlaylists?.Select(t => new YandexPlaylist
|
||||
{
|
||||
Uuid = t.PlaylistUuid,
|
||||
Kind = t.Kind,
|
||||
OwnerUid = t.Owner?.Uid ?? string.Empty,
|
||||
Title = t.Title,
|
||||
Description = t.Description,
|
||||
CoverUrl = t.Cover.GetUrl(),
|
||||
TrackCount = t.TrackCount,
|
||||
}).ToList();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<YandexSearchResult> SearchTracksByIdAsync(
|
||||
ApplicationUser user,
|
||||
string id,
|
||||
TrackSearchType searchType,
|
||||
int limit = 20
|
||||
TrackSearchType searchType
|
||||
)
|
||||
{
|
||||
YandexSearchResult result = new();
|
||||
@@ -254,8 +299,8 @@ public class YandexMusicService
|
||||
TrackCount = p.TrackCount,
|
||||
}).ToList();
|
||||
|
||||
|
||||
result.Tracks = artist.PopularTracks.Select(t => new YandexTrack
|
||||
var allTraks = await artist.Artist.GetAllTracksAsync();
|
||||
result.Tracks = allTraks?.Select(t => new YandexTrack
|
||||
{
|
||||
TrackId = t.Id,
|
||||
Title = t.Title,
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<p role="alert">You are not authorized to access this resource.</p>
|
||||
<p role="alert">У вас нет прав доступа к этому ресурсу.</p>
|
||||
}
|
||||
</NotAuthorized>
|
||||
</AuthorizeRouteView>
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Share"
|
||||
Color="Color.Default"
|
||||
OnClick="@TogglePopover"
|
||||
Title="Поделиться"
|
||||
Size="Size.Medium" />
|
||||
|
||||
<MudPopover Open="@_popoverOpen"
|
||||
|
||||
45
PlaylistShared.Pwa/Components/Common/ShareDialog.razor
Normal file
45
PlaylistShared.Pwa/Components/Common/ShareDialog.razor
Normal file
@@ -0,0 +1,45 @@
|
||||
@inject IJSRuntime JS
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
<MudDialog>
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">Поделиться плейлистом</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudStack Spacing="2">
|
||||
<MudText>Скопируйте ссылку и отправьте её друзьям:</MudText>
|
||||
<MudTextField @bind-Value="ShareUrl"
|
||||
Variant="Variant.Outlined"
|
||||
ReadOnly="true"
|
||||
Margin="Margin.Dense"
|
||||
Class="mt-2" />
|
||||
</MudStack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary"
|
||||
OnClick="CopyToClipboard">
|
||||
Скопировать ссылку
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Text" Color="Color.Default"
|
||||
OnClick="Close">
|
||||
Закрыть
|
||||
</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
private IMudDialogInstance MudDialog { get; set; } = default!;
|
||||
|
||||
[Parameter]
|
||||
public string ShareUrl { get; set; } = string.Empty;
|
||||
|
||||
private async Task CopyToClipboard()
|
||||
{
|
||||
await JS.InvokeVoidAsync("navigator.clipboard.writeText", ShareUrl);
|
||||
Snackbar.Add("Ссылка скопирована в буфер обмена!", Severity.Success);
|
||||
MudDialog.Close(DialogResult.Ok(true));
|
||||
}
|
||||
|
||||
private void Close() => MudDialog.Cancel();
|
||||
}
|
||||
23
PlaylistShared.Pwa/Components/Common/TrackItemSkeleton.razor
Normal file
23
PlaylistShared.Pwa/Components/Common/TrackItemSkeleton.razor
Normal file
@@ -0,0 +1,23 @@
|
||||
@using PlaylistShared.Pwa.Components.Common
|
||||
|
||||
<MudStack Class="py-2 px-0" Row AlignItems="AlignItems.Center">
|
||||
<!-- Обложка-скелет -->
|
||||
<MudItem>
|
||||
<MudSkeleton SkeletonType="SkeletonType.Rectangle" Width="40px" Height="40px" />
|
||||
</MudItem>
|
||||
|
||||
<!-- Информация о треке (две строки текста) -->
|
||||
<MudItem>
|
||||
<MudStack Spacing="0">
|
||||
<MudSkeleton SkeletonType="SkeletonType.Text" Width="180px" Class="my-0" />
|
||||
<MudSkeleton SkeletonType="SkeletonType.Text" Width="120px" Class="my-0" />
|
||||
</MudStack>
|
||||
</MudItem>
|
||||
|
||||
<MudSpacer />
|
||||
|
||||
<!-- Длительность-скелет -->
|
||||
<MudItem>
|
||||
<MudSkeleton SkeletonType="SkeletonType.Text" Width="30px" />
|
||||
</MudItem>
|
||||
</MudStack>
|
||||
@@ -6,22 +6,23 @@
|
||||
@inject AuthenticationStateProvider AuthProvider
|
||||
@inject ISnackbar Snackbar
|
||||
@inject HttpClient Http
|
||||
@implements IDisposable
|
||||
@implements IAsyncDisposable
|
||||
|
||||
<MudPaper Class="pa-2 rounded" Elevation="0" Width="100%" Style="background-color: rgba(0,0,0,0.05);">
|
||||
<MudStack Row AlignItems="AlignItems.Center" Wrap="Wrap.Wrap">
|
||||
<MudStack Spacing="1" Row AlignItems="AlignItems.Center" Wrap="Wrap.NoWrap">
|
||||
<!-- Кнопки управления -->
|
||||
<MudItem @onmouseenter="() => { _isPlayHovered = true; }"
|
||||
@onmouseleave="() => { _isPlayHovered = false; }"
|
||||
Class="relative d-inline-block rounded-sm overflow-hidden"
|
||||
style="cursor: pointer; width: 50px; height: 50px;">
|
||||
Class="relative d-inline-block rounded overflow-hidden cursor-pointer"
|
||||
Style="width: 50px; height: 50px;">
|
||||
|
||||
@if (!string.IsNullOrEmpty(AudioPlayerService.CurrentTrack?.CoverUri))
|
||||
{
|
||||
<MudImage Src="@AudioPlayerService.CurrentTrack.CoverUri.FormatCoverUrl(50, 50)" Height="50" Width="50" Class="rounded d-block" />
|
||||
}
|
||||
|
||||
<MudItem class="absolute d-flex align-center justify-center rounded"
|
||||
style="top: 0; left: 0; right: 0; bottom: 0; background: transparent;">
|
||||
<MudItem Class="absolute d-flex align-center justify-center rounded"
|
||||
Style="top: 0; left: 0; right: 0; bottom: 0; background: transparent;">
|
||||
<MudToggleIconButton Toggled="@AudioPlayerService.IsPlaying"
|
||||
Icon="@Icons.Material.Filled.PlayArrow"
|
||||
Color="@Color.Primary"
|
||||
@@ -35,7 +36,7 @@
|
||||
<!-- Название и прогресс -->
|
||||
@if (AudioPlayerService.CurrentTrack != null)
|
||||
{
|
||||
<MudStack AlignItems="AlignItems.Stretch" Class="d-flex flex-grow-1 relative overflow-hidden align-center rounded-sm" Style="height: 50px;">
|
||||
<MudStack Spacing="0" AlignItems="AlignItems.Stretch" Class="d-flex flex-grow-1 relative overflow-hidden align-center rounded" Style="height: 50px;">
|
||||
<MudItem Class="absolute" style="top: 0; left: 0; right: 0; bottom: 0; z-index: 1;">
|
||||
<TrackProgress Value="@AudioPlayerService.CurrentTime"
|
||||
Min="0" Max="@AudioPlayerService.TotalTime"
|
||||
@@ -48,21 +49,21 @@
|
||||
Buffer
|
||||
ValueChanged="SeekTo" />
|
||||
</MudItem>
|
||||
<MudStack Row AlignItems="AlignItems.Center" Class="px-3 relative pointer-events-none" Style="z-index: 2; width: 100%; height: 100%;">
|
||||
<MudStack AlignItems="AlignItems.Start" Spacing="0">
|
||||
<MudText Typo="Typo.body2" Color="Color.Default" Style="font-weight: 600;">
|
||||
<MudStack Spacing="0" Row AlignItems="AlignItems.Center" Class="px-3 relative pointer-events-none" Style="z-index: 2; width: 100%; height: 100%;">
|
||||
<MudStack AlignItems="AlignItems.Start" Spacing="0" Style="min-width: 0; width: 100%;">
|
||||
<MudText Typo="Typo.body2" Inline Color="Color.Default" Style="font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%;">
|
||||
@AudioPlayerService.CurrentTrack.Title
|
||||
</MudText>
|
||||
|
||||
<MudText Typo="Typo.body2" Style="font-weight: 600;">
|
||||
|
||||
<MudText Typo="Typo.body2" Inline Color="Color.Default" Style="font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%;">
|
||||
@string.Join(", ", AudioPlayerService.CurrentTrack.Artists.Select(a => a.Name))
|
||||
</MudText>
|
||||
</MudStack>
|
||||
|
||||
<MudSpacer />
|
||||
|
||||
<MudText Typo="Typo.body2" Style="font-family: monospace; font-weight: 600;">
|
||||
@AudioPlayerService.CurrentTimeString / @AudioPlayerService.TotalTimeString
|
||||
<MudText Typo="Typo.body2">
|
||||
@AudioPlayerService.CurrentTimeString
|
||||
</MudText>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
@@ -71,30 +72,31 @@
|
||||
{
|
||||
<MudSpacer />
|
||||
}
|
||||
|
||||
<!-- Громкость -->
|
||||
<MudItem @onmouseenter="() => _volumeIsOpen = true"
|
||||
@onmouseleave="() => _volumeIsOpen = false"
|
||||
@onwheel="OnVolumeHandleWheel"
|
||||
Style="position: relative; display: flex; align-items: center;">
|
||||
|
||||
<MudHidden Breakpoint="Breakpoint.SmAndDown">
|
||||
<!-- Громкость -->
|
||||
<MudItem @onmouseenter="() => _volumeIsOpen = true"
|
||||
@onmouseleave="() => _volumeIsOpen = false"
|
||||
@onwheel="OnVolumeHandleWheel"
|
||||
Style="position: relative; display: flex; align-items: center;">
|
||||
|
||||
<MudIconButton Icon="@(AudioPlayerService.CurrentVolume == 0 ? Icons.Material.Filled.VolumeOff : Icons.Material.Filled.VolumeUp)"
|
||||
Size="Size.Small"
|
||||
Color="Color.Default"
|
||||
OnClick="ToggleMute" />
|
||||
<MudIconButton Icon="@(AudioPlayerService.CurrentVolume == 0 ? Icons.Material.Filled.VolumeOff : Icons.Material.Filled.VolumeUp)"
|
||||
Size="Size.Small"
|
||||
Color="Color.Default"
|
||||
OnClick="ToggleMute" />
|
||||
|
||||
<MudPopover Open="@_volumeIsOpen"
|
||||
AnchorOrigin="Origin.TopCenter"
|
||||
TransformOrigin="Origin.BottomCenter"
|
||||
Fixed
|
||||
Class="pa-0 mt-n5"
|
||||
Style="height:120px; width: 10px; background-color: transparent !important; overflow: visible !important;">
|
||||
<MudProgressLinear Vertical Color="Color.Primary" Size="Size.Medium" Value="@AudioPlayerService.CurrentVolume" />
|
||||
<MudPopover Open="@_volumeIsOpen"
|
||||
AnchorOrigin="Origin.TopCenter"
|
||||
TransformOrigin="Origin.BottomCenter"
|
||||
Fixed
|
||||
Class="pa-0 mt-n5"
|
||||
Style="height:120px; width: 10px; background-color: transparent !important; overflow: visible !important;">
|
||||
<MudProgressLinear Vertical Color="Color.Primary" Size="Size.Medium" Value="@AudioPlayerService.CurrentVolume" />
|
||||
|
||||
</MudPopover>
|
||||
</MudItem>
|
||||
</MudPopover>
|
||||
</MudItem>
|
||||
</MudHidden>
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
|
||||
<audio id="@_audioId" style="display: none;"></audio>
|
||||
|
||||
@@ -123,6 +125,11 @@
|
||||
AudioPlayerService.OnStateChanged += OnServiceStateChanged;
|
||||
}
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
@@ -286,6 +293,13 @@
|
||||
{
|
||||
try
|
||||
{
|
||||
AudioPlayerService.OnLoadAndPlayRequested -= OnServiceLoadAndPlay;
|
||||
AudioPlayerService.OnPlayRequested -= OnServicePlay;
|
||||
AudioPlayerService.OnPauseRequested -= OnServicePause;
|
||||
AudioPlayerService.OnSeekRequested -= OnServiceSeek;
|
||||
AudioPlayerService.OnVolumeChangeRequested -= OnServiceVolumeChange;
|
||||
AudioPlayerService.OnStateChanged -= OnServiceStateChanged;
|
||||
|
||||
if (_audioElement != null)
|
||||
await _audioElement.DisposeAsync();
|
||||
if (_audioModule != null)
|
||||
@@ -293,4 +307,9 @@
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
DisposeAsync().AsTask().Wait();
|
||||
}
|
||||
}
|
||||
30
PlaylistShared.Pwa/Components/Global/ContextualBarContent.cs
Normal file
30
PlaylistShared.Pwa/Components/Global/ContextualBarContent.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using PlaylistShared.Pwa.Services;
|
||||
|
||||
namespace PlaylistShared.Pwa.Components.Global;
|
||||
|
||||
public class ContextualBarContent : ComponentBase, IDisposable
|
||||
{
|
||||
[Inject]
|
||||
public ContextualActionBarService ContextualActionBarService { get; set; } = default!;
|
||||
|
||||
[Parameter]
|
||||
public ContextualActionBarPosition Position { get; set; } = ContextualActionBarPosition.Default;
|
||||
|
||||
[Parameter]
|
||||
public RenderFragment? ChildContent { get; set; }
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
ContextualActionBarService.Content = ChildContent;
|
||||
ContextualActionBarService.Position = Position;
|
||||
ContextualActionBarService.ChangeParameters();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
ContextualActionBarService.Content = null;
|
||||
ContextualActionBarService.Position = ContextualActionBarPosition.Default;
|
||||
ContextualActionBarService.ChangeParameters();
|
||||
}
|
||||
}
|
||||
@@ -82,7 +82,6 @@
|
||||
width: 100%;
|
||||
height: var(--track-height);
|
||||
background-color: var(--mud-palette-action-disabled-background, rgba(0,0,0,0.1));
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,312 +0,0 @@
|
||||
@using PlaylistShared.Pwa.Components.Common
|
||||
@using PlaylistShared.Pwa.Components.SharedPlaylist.Cards
|
||||
@using PlaylistShared.Shared.DTO
|
||||
@using PlaylistShared.Shared.Enums
|
||||
@using PlaylistShared.Shared.SharedPlaylist
|
||||
@using PlaylistShared.Shared.Yandex
|
||||
@inject HttpClient Http
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
<MudStack Style="height: 100%; overflow: hidden;">
|
||||
<MudItem>
|
||||
<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>
|
||||
</MudItem>
|
||||
|
||||
<MudItem Style="overflow: auto; flex-grow:1;">
|
||||
@if (_isSearching)
|
||||
{
|
||||
<MudProgressCircular Indeterminate Class="mx-auto my-8" />
|
||||
}
|
||||
else if (_searchResult != null)
|
||||
{
|
||||
<MudExpansionPanels>
|
||||
@* Секция исполнителей *@
|
||||
@if (_searchResult?.Artists != null)
|
||||
{
|
||||
<MudExpansionPanel Text="Исполнители" Expanded="true">
|
||||
<MudGrid>
|
||||
@foreach (var artist in _searchResult.Artists)
|
||||
{
|
||||
<MudItem xs="12" sm="6" md="3" lg="2">
|
||||
<ArtistCard Item="artist" OnClick="() => SearchTracksByEntity(artist.Id, artist.Name, TrackSearchType.Artist)" />
|
||||
</MudItem>
|
||||
}
|
||||
</MudGrid>
|
||||
</MudExpansionPanel>
|
||||
}
|
||||
|
||||
@* Секция альбомов *@
|
||||
@if (_searchResult?.Albums != null)
|
||||
{
|
||||
<MudExpansionPanel Text="Альбомы" Expanded="true">
|
||||
<MudGrid>
|
||||
@foreach (var album in _searchResult.Albums)
|
||||
{
|
||||
<MudItem xs="12" sm="6" md="3" lg="2">
|
||||
<AlbumCard Item="album" OnClick="() => SearchTracksByEntity(album.Id, album.Title, TrackSearchType.Album)" />
|
||||
</MudItem>
|
||||
}
|
||||
</MudGrid>
|
||||
</MudExpansionPanel>
|
||||
}
|
||||
|
||||
@* Секция плейлистов *@
|
||||
@if (_searchResult?.Playlists != null)
|
||||
{
|
||||
<MudExpansionPanel Text="Плейлисты" Expanded="true">
|
||||
<MudGrid>
|
||||
@foreach (var playlist in _searchResult.Playlists)
|
||||
{
|
||||
<MudItem xs="12" sm="6" md="3" lg="2">
|
||||
<PlaylistCard Item="playlist" OnClick="() => SearchTracksByEntity(playlist.Uuid, playlist.Title, TrackSearchType.Playlist)" />
|
||||
</MudItem>
|
||||
}
|
||||
</MudGrid>
|
||||
</MudExpansionPanel>
|
||||
}
|
||||
|
||||
@* Секция треков *@
|
||||
@if (_searchResult?.Tracks != null)
|
||||
{
|
||||
<MudExpansionPanel Text="Треки" Expanded="true">
|
||||
<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="@ShareToken" />
|
||||
</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>
|
||||
</MudExpansionPanel>
|
||||
}
|
||||
</MudExpansionPanels>
|
||||
}
|
||||
</MudItem>
|
||||
</MudStack>
|
||||
|
||||
@code {
|
||||
[Parameter] public string ShareToken { get; set; } = string.Empty;
|
||||
[Parameter] public EventCallback OnTrackAdded { get; set; }
|
||||
[Parameter] public EventCallback OnTrackRemoved { get; set; }
|
||||
[Parameter] public HashSet<string> ExistingTrackIds { get; set; } = new();
|
||||
|
||||
private string _searchQuery = "";
|
||||
private bool _isSearching = false;
|
||||
private TrackSearchType _searchType = TrackSearchType.All;
|
||||
private YandexSearchResult? _searchResult = null;
|
||||
|
||||
private async Task OnSearchQueryChanged()
|
||||
{
|
||||
await SearchTracks(byId: false);
|
||||
}
|
||||
|
||||
private async Task OnSearchTypeChanged()
|
||||
{
|
||||
await SearchTracks(byId: false);
|
||||
}
|
||||
|
||||
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(ShareToken))
|
||||
url += $"&shared_id={Uri.EscapeDataString(ShareToken)}";
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SearchTracksByEntity(string entityId, string title, TrackSearchType entityType)
|
||||
{
|
||||
// Переключаем тип и ищем по ID
|
||||
_searchType = entityType;
|
||||
_searchQuery = title;
|
||||
await SearchTracks(byId: true, forcedQuery: entityId);
|
||||
}
|
||||
|
||||
private async Task ToggleTrack(YandexTrack track)
|
||||
{
|
||||
if (ExistingTrackIds.Contains(track.TrackId))
|
||||
await RemoveTrack(track);
|
||||
else
|
||||
await AddTrack(track);
|
||||
}
|
||||
|
||||
private async Task RemoveTrack(YandexTrack track)
|
||||
{
|
||||
if (!ExistingTrackIds.Remove(track.TrackId)) return;
|
||||
|
||||
try
|
||||
{
|
||||
await RemoveTrackById(track.TrackId);
|
||||
await OnTrackRemoved.InvokeAsync();
|
||||
Snackbar.Add($"Трек \"{track.Title}\" удален", Severity.Success, c => c.SnackbarVariant = Variant.Outlined);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"{ex.Message}", Severity.Error);
|
||||
ExistingTrackIds.Add(track.TrackId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RemoveTrackById(string trackId)
|
||||
{
|
||||
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>>();
|
||||
throw new Exception(error?.Error?.Message ?? "Ошибка удаления трека");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddTrack(YandexTrack track)
|
||||
{
|
||||
if (ExistingTrackIds.Contains(track.TrackId)) return;
|
||||
ExistingTrackIds.Add(track.TrackId);
|
||||
|
||||
try
|
||||
{
|
||||
await AddTrackById(track.TrackId);
|
||||
await OnTrackAdded.InvokeAsync();
|
||||
Snackbar.Add($"Трек \"{track.Title}\" добавлен", Severity.Success, c => c.SnackbarVariant = Variant.Outlined);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"{ex.Message}", Severity.Error);
|
||||
ExistingTrackIds.Remove(track.TrackId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddTrackById(string trackId)
|
||||
{
|
||||
var request = new UpdateTrackListRequest { TrackIds = new List<string> { trackId } };
|
||||
var response = await Http.PostAsJsonAsync($"/api/sharedplaylist/{ShareToken}/add-tracks", request);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
await OnTrackAdded.InvokeAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
var error = await response.Content.ReadFromJsonAsync<ApiResponse<object>>();
|
||||
throw new Exception(error?.Error?.Message ?? "Ошибка добавления трека");
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
@using PlaylistShared.Pwa.Components.Common
|
||||
|
||||
<MudItem Class="d-flex flex-column align-center pa-2">
|
||||
<!-- Аватар-скелет -->
|
||||
<MudAvatar Size="MudBlazor.Size.Large">
|
||||
<MudSkeleton SkeletonType="SkeletonType.Circle" Width="@Size.ToString()" Height="@Size.ToString()" />
|
||||
</MudAvatar>
|
||||
|
||||
<!-- Текст-скелет -->
|
||||
<MudSkeleton SkeletonType="SkeletonType.Text" Width="50px" Class="mt-2" />
|
||||
<MudSkeleton SkeletonType="SkeletonType.Text" Width="30px" Class="ma-0" />
|
||||
</MudItem>
|
||||
|
||||
@code {
|
||||
[Parameter] public int Size { get; set; } = 50;
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
@using PlaylistShared.Pwa.Components.Common
|
||||
@using PlaylistShared.Shared.Enums
|
||||
@using System.Security.Claims
|
||||
@using PlaylistShared.Shared.SharedPlaylist
|
||||
@inject HttpClient Http
|
||||
@inject NavigationManager Navigation
|
||||
@inject AuthenticationStateProvider AuthProvider
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
|
||||
<MudStack Row AlignItems="AlignItems.Center">
|
||||
@if (!string.IsNullOrEmpty(Playlist?.CoverUrl))
|
||||
{
|
||||
<MudImage Src="@Playlist.CoverUrl.FormatCoverUrl(80, 80)" Height="80" Width="80" Class="rounded" />
|
||||
}
|
||||
<MudStack>
|
||||
<MudStack Row AlignItems="AlignItems.Center" Wrap="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" />
|
||||
}
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
|
||||
@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 (Playlist == null) return;
|
||||
|
||||
if (!_isAuthenticated)
|
||||
{
|
||||
Snackbar.Add("Добавление в избранное только авторизованным пользователям", Severity.Warning);
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,33 @@ public static class LongExtensions
|
||||
/// <summary>
|
||||
/// Преобразует миллисекунды в формат Минуты:Секунды
|
||||
/// </summary>
|
||||
public static string FormatDuration(this long ms)
|
||||
public static string FormatDuration(this long ms, FormatDurationType format = FormatDurationType.mmss)
|
||||
{
|
||||
var seconds = ms / 1000;
|
||||
var mins = seconds / 60;
|
||||
var secs = seconds % 60;
|
||||
return $"{mins}:{secs:D2}";
|
||||
|
||||
var mm = seconds / 60;
|
||||
var ss = seconds % 60;
|
||||
|
||||
if (format == FormatDurationType.mmss || mm < 60)
|
||||
{
|
||||
return $"{mm}:{ss:D2}";
|
||||
}
|
||||
else if (format == FormatDurationType.hhmmss)
|
||||
{
|
||||
var hh = mm / 60;
|
||||
mm = mm / 60;
|
||||
|
||||
return $"{hh}:{mm:D2}:{ss:D2}";
|
||||
}
|
||||
else
|
||||
{
|
||||
return $"{mm}:{ss:D2}";
|
||||
}
|
||||
}
|
||||
|
||||
public enum FormatDurationType
|
||||
{
|
||||
mmss,
|
||||
hhmmss,
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
public static class CustomIcons
|
||||
{
|
||||
// SVG путь для логотипа Яндекса (буква Я в круге или просто Я)
|
||||
public const string Yandex = "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm.72 15.79h-2.14v-1.58c-.37.49-.87.89-1.48 1.18-.61.29-1.29.44-2.03.44-1.2 0-2.13-.34-2.8-.1-1.02-.66-1.52-1.61-1.52-2.84 0-1.25.43-2.22 1.28-2.91.85-.69 2.05-1.04 3.59-1.04h1.1v-.84c0-.62-.15-1.07-.46-1.34-.31-.27-.79-.41-1.44-.41-.53 0-1.02.08-1.48.24-.46.16-.9.41-1.32.74v-1.8c.48-.25 1.01-.45 1.58-.59.57-.14 1.15-.21 1.74-.21 1.45 0 2.53.33 3.23 1 .7.67 1.05 1.66 1.05 2.97v6.29zm-2.14-5.18h-.9c-.8 0-1.4.15-1.8.44-.4.29-.6.74-.6 1.34 0 .55.16.96.48 1.23.32.27.76.41 1.32.41.51 0 .97-.13 1.37-.39.4-.26.6-.64.6-1.14v-1.89z";
|
||||
}
|
||||
|
||||
public const string Yandex = @"<path d='M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm.72 15.79h-2.14v-1.58c-.37.49-.87.89-1.48 1.18-.61.29-1.29.44-2.03.44-1.2 0-2.13-.34-2.8-.1-1.02-.66-1.52-1.61-1.52-2.84 0-1.25.43-2.22 1.28-2.91.85-.69 2.05-1.04 3.59-1.04h1.1v-.84c0-.62-.15-1.07-.46-1.34-.31-.27-.79-.41-1.44-.41-.53 0-1.02.08-1.48.24-.46.16-.9.41-1.32.74v-1.8c.48-.25 1.01-.45 1.58-.59.57-.14 1.15-.21 1.74-.21 1.45 0 2.53.33 3.23 1 .7.67 1.05 1.66 1.05 2.97v6.29zm-2.14-5.18h-.9c-.8 0-1.4.15-1.8.44-.4.29-.6.74-.6 1.34 0 .55.16.96.48 1.23.32.27.76.41 1.32.41.51 0 .97-.13 1.37-.39.4-.26.6-.64.6-1.14v-1.89z'/>";
|
||||
public const string YandexMusic = "<path d='M23.8 9.4l-.1-.5-3.9-.9 2-3-.2-.3-3.1 1.5.3-4.2-.3-.2-2 3.4L14.3 0h-.4l.6 4.9-5.7-4.5-.5.1 4.4 5.5-8.7-2.9-.4.4 7.8 4.4-10.7.9-.1.7 11.2 1.2-9.3 7.6.4.6 11.1-6.1-2.2 10.6h.7l4.3-10 2.6 7.8.4-.3-.9-7.8 3.9 4.5.2-.4-2.9-5.5 4.2 1.5.1-.5-3.5-2.8 3.3-.7z'/>";
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
@using MudBlazor.Services
|
||||
@using PlaylistShared.Pwa.Components.Global
|
||||
@inherits LayoutComponentBase
|
||||
@inject PwaUpdateService PwaUpdateService
|
||||
@inject IJSRuntime JSRuntime
|
||||
@inject ContextualActionBarService ContextualActionBarService
|
||||
@inject IBrowserViewportService BrowserViewportService
|
||||
@implements IBrowserViewportObserver
|
||||
|
||||
<MudThemeProvider Theme="@_theme" IsDarkMode="_isDarkMode" />
|
||||
<MudPopoverProvider />
|
||||
@@ -7,36 +13,49 @@
|
||||
<MudSnackbarProvider />
|
||||
|
||||
<MudLayout>
|
||||
<MudAppBar Elevation="1">
|
||||
<MudAppBar Elevation="1" Contextual Bottom = "@_actionBarBottom" Fixed>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Menu" Color="Color.Inherit" Edge="Edge.Start" OnClick="@DrawerToggle" />
|
||||
<MudSpacer />
|
||||
<LoginDisplay />
|
||||
<MudIconButton Icon="@(DarkLightModeButtonIcon)" Color="Color.Inherit" OnClick="@DarkModeToggle" Class="ml-2" />
|
||||
<MudLink Href="https://git.frigat.duckdns.org/FrigaT/PlaylistShared" Target="_blank" Color="Color.Inherit" Underline="Underline.None" Class="ml-4">
|
||||
<MudIcon Icon="@Icons.Custom.Brands.GitHub" Size="Size.Small" Class="mr-1" /> Git
|
||||
</MudLink>
|
||||
@if (_actionBarContent != null)
|
||||
{
|
||||
@_actionBarContent
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudSpacer />
|
||||
<LoginDisplay />
|
||||
<MudIconButton Icon="@(DarkLightModeButtonIcon)" Color="Color.Inherit" OnClick="@DarkModeToggle" Class="ml-2" />
|
||||
<MudLink Href="https://git.frigat.duckdns.org/FrigaT/PlaylistShared" Target="_blank" Color="Color.Inherit" Underline="Underline.None" Class="ml-4">
|
||||
<MudIcon Icon="@Icons.Custom.Brands.GitHub" Size="Size.Small" Class="mr-1" /> Git
|
||||
</MudLink>
|
||||
}
|
||||
</MudAppBar>
|
||||
|
||||
<MudDrawer @bind-Open="_drawerOpen" ClipMode="DrawerClipMode.Always" Elevation="2">
|
||||
<MudDrawer @bind-Open="_drawerOpen" Class="@(_actionBarBottom ? " pt-0 pb-16" : "")" ClipMode="DrawerClipMode.Always" Elevation="2">
|
||||
<NavMenu />
|
||||
</MudDrawer>
|
||||
|
||||
<MudMainContent Class="pt-16 d-flex flex-column" Style="height: 100vh;">
|
||||
<MudItem Class="flex-grow-1 overflow-y-auto">
|
||||
@Body
|
||||
</MudItem>
|
||||
|
||||
<MudItem>
|
||||
<AudioPlayer />
|
||||
</MudItem>
|
||||
<MudMainContent Class="@("d-flex flex-column" + (_actionBarBottom ? " pt-0 pb-16" : ""))" Style="height: 100dvh;">
|
||||
@Body
|
||||
</MudMainContent>
|
||||
</MudLayout>
|
||||
|
||||
@code {
|
||||
private RenderFragment? _actionBarContent;
|
||||
private bool _actionBarBottom => _contextualPosition switch
|
||||
{
|
||||
ContextualActionBarPosition.Bottom => true,
|
||||
ContextualActionBarPosition.Top => false,
|
||||
_ => _isMobile,
|
||||
};
|
||||
private bool _isMobile = false;
|
||||
private ContextualActionBarPosition _contextualPosition = ContextualActionBarPosition.Default;
|
||||
|
||||
private bool _drawerOpen = true;
|
||||
private bool _isDarkMode = true;
|
||||
private MudTheme? _theme;
|
||||
|
||||
private DotNetObjectReference<PwaUpdateService>? _dotNetRef;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
base.OnInitialized();
|
||||
@@ -47,6 +66,25 @@
|
||||
PaletteDark = _darkPalette,
|
||||
LayoutProperties = new LayoutProperties()
|
||||
};
|
||||
|
||||
ContextualActionBarService.OnChanged += OnContextualChangedHandler;
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
_dotNetRef = DotNetObjectReference.Create(PwaUpdateService);
|
||||
await JSRuntime.InvokeVoidAsync("registerSWMessageHandler", _dotNetRef);
|
||||
await BrowserViewportService.SubscribeAsync(this, fireImmediately: true);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnContextualChangedHandler()
|
||||
{
|
||||
_actionBarContent = ContextualActionBarService.Content;
|
||||
_contextualPosition = ContextualActionBarService.Position;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void DrawerToggle()
|
||||
@@ -103,4 +141,21 @@
|
||||
true => Icons.Material.Rounded.AutoMode,
|
||||
false => Icons.Material.Outlined.DarkMode,
|
||||
};
|
||||
|
||||
|
||||
|
||||
Guid IBrowserViewportObserver.Id { get; } = Guid.NewGuid();
|
||||
|
||||
ResizeOptions IBrowserViewportObserver.ResizeOptions { get; } = new()
|
||||
{
|
||||
ReportRate = 250,
|
||||
NotifyOnBreakpointOnly = true
|
||||
};
|
||||
|
||||
Task IBrowserViewportObserver.NotifyBrowserViewportChangeAsync(BrowserViewportEventArgs browserViewportEventArgs)
|
||||
{
|
||||
_isMobile = browserViewportEventArgs.Breakpoint <= Breakpoint.Sm;
|
||||
|
||||
return InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<MudCard>
|
||||
<MudCardContent Class="text-center">
|
||||
<MudText Typo="Typo.h5" Class="mb-4">Вход в PlaylistShared</MudText>
|
||||
|
||||
@*
|
||||
<MudText Typo="Typo.body2" Class="mb-6">
|
||||
Войдите через учётную запись Keycloak или используйте локальный аккаунт.
|
||||
</MudText>
|
||||
@@ -22,7 +22,7 @@
|
||||
</MudButton>
|
||||
|
||||
<MudDivider Class="my-4">или</MudDivider>
|
||||
|
||||
*@
|
||||
<!-- Локальная форма входа -->
|
||||
<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" @bind-Value:after="LocalLogin" />
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -28,6 +28,9 @@
|
||||
<Content Update="wwwroot\js\AudioPlayer.js">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Update="wwwroot\js\shareUtils.js">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -20,10 +20,12 @@ internal class Program
|
||||
return new HttpClient { BaseAddress = new Uri(apiUrl) };
|
||||
});
|
||||
|
||||
builder.Services.AddScoped<PwaUpdateService>();
|
||||
builder.Services.AddScoped<TokenStorage>();
|
||||
builder.Services.AddScoped<PlayerStorage>();
|
||||
builder.Services.AddScoped<AuthStateProvider>();
|
||||
builder.Services.AddScoped<AuthenticationStateProvider>(sp => sp.GetRequiredService<AuthStateProvider>());
|
||||
builder.Services.AddScoped<ContextualActionBarService>();
|
||||
builder.Services.AddScoped<ApiClient>();
|
||||
builder.Services.AddScoped<IAudioPlayerService, AudioPlayerService>();
|
||||
|
||||
|
||||
@@ -10,6 +10,23 @@
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
|
||||
"https (silent)": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"applicationUrl": "https://localhost:7225;http://localhost:5181",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
|
||||
"https (prod)": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
|
||||
"applicationUrl": "https://localhost:7225;http://localhost:5181"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,8 @@ public class AudioPlayerService : IAudioPlayerService
|
||||
public string TotalTimeString => _totalTimeString;
|
||||
|
||||
public event Action? OnStateChanged;
|
||||
public event Action? OnStartedTrack;
|
||||
public event Action? OnEndedTrack;
|
||||
|
||||
public AudioPlayerService(TokenStorage tokenStorage, ISnackbar snackbar, HttpClient httpClient, PlayerStorage playerStorage)
|
||||
{
|
||||
@@ -105,6 +107,7 @@ public class AudioPlayerService : IAudioPlayerService
|
||||
_isPlaying = true;
|
||||
OnStateChanged?.Invoke();
|
||||
OnLoadAndPlayRequested?.Invoke(trackId, accessToken, playlistShareToken);
|
||||
OnStartedTrack?.Invoke();
|
||||
}
|
||||
|
||||
public async Task PlayAsync()
|
||||
@@ -112,6 +115,7 @@ public class AudioPlayerService : IAudioPlayerService
|
||||
_isPlaying = true;
|
||||
OnStateChanged?.Invoke();
|
||||
OnPlayRequested?.Invoke();
|
||||
OnStartedTrack?.Invoke();
|
||||
}
|
||||
|
||||
public async Task PauseAsync()
|
||||
@@ -175,6 +179,7 @@ public class AudioPlayerService : IAudioPlayerService
|
||||
_totalTime = 0;
|
||||
_currentTimeString = "0:00";
|
||||
OnStateChanged?.Invoke();
|
||||
OnEndedTrack?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -12,6 +12,8 @@ public class AuthStateProvider : AuthenticationStateProvider, IDisposable
|
||||
private readonly HttpClient _http;
|
||||
private Timer? _refreshTimer;
|
||||
private ClaimsPrincipal _currentUser = new(new ClaimsIdentity());
|
||||
private string? _currentToken;
|
||||
private string? _currentRefreshToken;
|
||||
|
||||
public AuthStateProvider(TokenStorage tokenStorage, ApiClient apiClient, HttpClient http)
|
||||
{
|
||||
@@ -26,14 +28,59 @@ public class AuthStateProvider : AuthenticationStateProvider, IDisposable
|
||||
if (string.IsNullOrEmpty(token))
|
||||
return new AuthenticationState(_currentUser);
|
||||
|
||||
var principal = ParseToken(token);
|
||||
if (principal == null)
|
||||
return new AuthenticationState(_currentUser);
|
||||
var (isValid, principal) = await ValidateTokenAsync(token);
|
||||
if (isValid && principal != null)
|
||||
{
|
||||
_currentUser = principal;
|
||||
_currentToken = token;
|
||||
_currentRefreshToken = refreshToken;
|
||||
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
ScheduleTokenRefresh(token, refreshToken);
|
||||
return new AuthenticationState(principal);
|
||||
}
|
||||
|
||||
_currentUser = principal;
|
||||
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
ScheduleTokenRefresh(token, refreshToken);
|
||||
return new AuthenticationState(principal);
|
||||
// Токен невалиден – пробуем обновить
|
||||
if (!string.IsNullOrEmpty(refreshToken))
|
||||
{
|
||||
var newTokenResponse = await _apiClient.RefreshTokenAsync(refreshToken);
|
||||
if (newTokenResponse != null && !string.IsNullOrEmpty(newTokenResponse.Token))
|
||||
{
|
||||
await MarkUserAsAuthenticated(newTokenResponse.Token, newTokenResponse.RefreshToken);
|
||||
// После MarkUserAsAuthenticated состояние обновится через NotifyAuthenticationStateChanged,
|
||||
// но текущий вызов всё равно должен вернуть нового пользователя
|
||||
var (newIsValid, newPrincipal) = await ValidateTokenAsync(newTokenResponse.Token);
|
||||
if (newIsValid && newPrincipal != null)
|
||||
return new AuthenticationState(newPrincipal);
|
||||
}
|
||||
}
|
||||
|
||||
// Всё плохо — логаут
|
||||
await MarkUserAsLoggedOut();
|
||||
return new AuthenticationState(_currentUser);
|
||||
}
|
||||
|
||||
// Вспомогательный метод проверки валидности токена (включая срок)
|
||||
private async Task<(bool IsValid, ClaimsPrincipal? Principal)> ValidateTokenAsync(string token)
|
||||
{
|
||||
try
|
||||
{
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
var jwt = handler.ReadJwtToken(token);
|
||||
|
||||
// Проверяем, не истёк ли токен
|
||||
if (jwt.ValidTo < DateTime.UtcNow)
|
||||
{
|
||||
return (false, null);
|
||||
}
|
||||
|
||||
var identity = new ClaimsIdentity(jwt.Claims, "jwt");
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
return (true, principal);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return (false, null);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task MarkUserAsAuthenticated(string token, string refreshToken)
|
||||
@@ -41,6 +88,8 @@ public class AuthStateProvider : AuthenticationStateProvider, IDisposable
|
||||
await _tokenStorage.SetTokensAsync(token, refreshToken);
|
||||
var principal = ParseToken(token);
|
||||
_currentUser = principal ?? new ClaimsPrincipal(new ClaimsIdentity());
|
||||
_currentToken = token;
|
||||
_currentRefreshToken = refreshToken;
|
||||
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
|
||||
}
|
||||
@@ -49,10 +98,51 @@ public class AuthStateProvider : AuthenticationStateProvider, IDisposable
|
||||
{
|
||||
await _tokenStorage.ClearTokensAsync();
|
||||
_currentUser = new ClaimsPrincipal(new ClaimsIdentity());
|
||||
_currentToken = null;
|
||||
_currentRefreshToken = null;
|
||||
_http.DefaultRequestHeaders.Authorization = null;
|
||||
_refreshTimer?.Dispose();
|
||||
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
|
||||
}
|
||||
|
||||
public async Task<string?> TryRefreshTokenAsync()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_currentRefreshToken))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
var newToken = await _apiClient.RefreshTokenAsync(_currentRefreshToken);
|
||||
if (newToken != null && !string.IsNullOrEmpty(newToken.Token))
|
||||
{
|
||||
await MarkUserAsAuthenticated(newToken.Token, newToken.RefreshToken);
|
||||
return newToken.Token;
|
||||
}
|
||||
else
|
||||
{
|
||||
await MarkUserAsLoggedOut();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
await MarkUserAsLoggedOut();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsTokenExpiringSoon()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_currentToken))
|
||||
return false;
|
||||
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
var jwt = handler.ReadJwtToken(_currentToken);
|
||||
var expiresAt = jwt.ValidTo;
|
||||
var timeToExpiry = expiresAt - DateTime.UtcNow;
|
||||
return timeToExpiry < TimeSpan.FromMinutes(1);
|
||||
}
|
||||
|
||||
private ClaimsPrincipal? ParseToken(string token)
|
||||
{
|
||||
try
|
||||
@@ -62,7 +152,7 @@ public class AuthStateProvider : AuthenticationStateProvider, IDisposable
|
||||
var identity = new ClaimsIdentity(jwt.Claims, "jwt");
|
||||
return new ClaimsPrincipal(identity);
|
||||
}
|
||||
catch (Exception ex)
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
@@ -81,18 +171,7 @@ public class AuthStateProvider : AuthenticationStateProvider, IDisposable
|
||||
_refreshTimer?.Dispose();
|
||||
_refreshTimer = new Timer(async _ =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var newToken = await _apiClient.RefreshTokenAsync(refreshToken);
|
||||
if (newToken != null)
|
||||
await MarkUserAsAuthenticated(newToken.Token, newToken.RefreshToken);
|
||||
else
|
||||
await MarkUserAsLoggedOut();
|
||||
}
|
||||
catch
|
||||
{
|
||||
await MarkUserAsLoggedOut();
|
||||
}
|
||||
await TryRefreshTokenAsync();
|
||||
}, null, (int)refreshTime.TotalMilliseconds, Timeout.Infinite);
|
||||
}
|
||||
}
|
||||
|
||||
25
PlaylistShared.Pwa/Services/ContextualActionBarService.cs
Normal file
25
PlaylistShared.Pwa/Services/ContextualActionBarService.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace PlaylistShared.Pwa.Services;
|
||||
|
||||
public class ContextualActionBarService
|
||||
{
|
||||
// Событие, которое будет вызываться при изменении содержимого панели
|
||||
public event Action? OnChanged;
|
||||
|
||||
public RenderFragment? Content { get; set; } = null;
|
||||
|
||||
public ContextualActionBarPosition Position { get; set; } = ContextualActionBarPosition.Default;
|
||||
|
||||
public void ChangeParameters()
|
||||
{
|
||||
OnChanged?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
public enum ContextualActionBarPosition
|
||||
{
|
||||
Default,
|
||||
Top,
|
||||
Bottom,
|
||||
}
|
||||
@@ -66,6 +66,10 @@ public interface IAudioPlayerService
|
||||
/// Подписывайтесь на него, чтобы перерисовывать UI (например, иконку "пауза/плей").
|
||||
/// </summary>
|
||||
event Action? OnStateChanged;
|
||||
|
||||
event Action? OnStartedTrack;
|
||||
|
||||
event Action? OnEndedTrack;
|
||||
#endregion
|
||||
|
||||
#region События для связи с реальным компонентом AudioPlayer (Эти события вызываются сервисом)
|
||||
|
||||
33
PlaylistShared.Pwa/Services/PwaUpdateService.cs
Normal file
33
PlaylistShared.Pwa/Services/PwaUpdateService.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
using Microsoft.JSInterop;
|
||||
using MudBlazor;
|
||||
|
||||
namespace PlaylistShared.Pwa.Services;
|
||||
|
||||
public class PwaUpdateService
|
||||
{
|
||||
private readonly ISnackbar _snackbar;
|
||||
private readonly IJSRuntime _jsRuntime;
|
||||
|
||||
public PwaUpdateService(ISnackbar snackbar, IJSRuntime jsRuntime)
|
||||
{
|
||||
_snackbar = snackbar;
|
||||
_jsRuntime = jsRuntime;
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public void OnNewVersionAvailable()
|
||||
{
|
||||
_snackbar.Add("Доступна новая версия! Обновите страницу.", Severity.Info, configure: options =>
|
||||
{
|
||||
options.Action = "Обновить";
|
||||
options.ShowCloseIcon = false;
|
||||
options.RequireInteraction = true;
|
||||
options.OnClick = _ =>
|
||||
{
|
||||
_jsRuntime.InvokeVoidAsync("location.reload", true);
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
options.CloseAfterNavigation = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,12 @@ events {
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
# Не раскрывайть версию Nginx в ответах.
|
||||
server_tokens off;
|
||||
|
||||
# Ограничение запросов от одного IP‑адреса, чтобы предотвратить DDoS‑атаки и злоупотребление ресурсами.
|
||||
limit_req_zone $binary_remote_addr zone=one:10m rate=60r/s;
|
||||
|
||||
# Сжатие
|
||||
gzip on;
|
||||
@@ -20,6 +26,8 @@ http {
|
||||
|
||||
# Для Service Worker – запрещаем кэширование, чтобы он всегда был свежим
|
||||
location = /service-worker.js {
|
||||
etag off;
|
||||
add_header Last-Modified "";
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
add_header Expires "0";
|
||||
@@ -28,6 +36,8 @@ http {
|
||||
|
||||
# Для файла манифеста Service Worker assets – тоже не кэшируем
|
||||
location = /service-worker-assets.js {
|
||||
etag off;
|
||||
add_header Last-Modified "";
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
add_header Expires "0";
|
||||
@@ -36,6 +46,8 @@ http {
|
||||
|
||||
# Для файла index.html – тоже не кэшируем
|
||||
location = /index.html {
|
||||
etag off;
|
||||
add_header Last-Modified "";
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
add_header Expires "0";
|
||||
|
||||
@@ -168,6 +168,15 @@
|
||||
<script src="_content/MudBlazor/MudBlazor.min.js"></script>
|
||||
<script src="_framework/blazor.webassembly#[.{fingerprint}].js"></script>
|
||||
<script>navigator.serviceWorker.register('service-worker.js', { updateViaCache: 'none' });</script>
|
||||
<script>
|
||||
function registerSWMessageHandler(dotNetHelper) {
|
||||
navigator.serviceWorker.addEventListener('message', event => {
|
||||
if (event.data && event.data.type === 'SW_ACTIVATED') {
|
||||
dotNetHelper.invokeMethodAsync('OnNewVersionAvailable');
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
19
PlaylistShared.Pwa/wwwroot/js/shareUtils.js
Normal file
19
PlaylistShared.Pwa/wwwroot/js/shareUtils.js
Normal file
@@ -0,0 +1,19 @@
|
||||
export function isSupported() {
|
||||
return !!navigator.share;
|
||||
}
|
||||
|
||||
export async function shareLink(title, text, url) {
|
||||
if (!navigator.share) {
|
||||
return { success: false, error: 'Web Share API не поддерживается' };
|
||||
}
|
||||
try {
|
||||
await navigator.share({ title, text, url });
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') {
|
||||
return { success: false, cancelled: true };
|
||||
}
|
||||
console.error('Ошибка при шеринге:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,15 @@
|
||||
"short_name": "PlaylistShare",
|
||||
"id": "./",
|
||||
"start_url": "./",
|
||||
"scope": "/",
|
||||
"handle_links": "preferred",
|
||||
"display": "standalone",
|
||||
"background_color": "#1a1a27",
|
||||
"theme_color": "#7e6fff",
|
||||
"theme_color": "#1a1a27",
|
||||
"launch_handler": {
|
||||
"client_mode": "focus-existing"
|
||||
},
|
||||
|
||||
"prefer_related_applications": false,
|
||||
"icons": [
|
||||
{
|
||||
|
||||
@@ -8,20 +8,17 @@ self.addEventListener('fetch', event => event.respondWith(onFetch(event)));
|
||||
|
||||
const cacheNamePrefix = 'offline-cache-';
|
||||
const cacheName = `${cacheNamePrefix}${self.assetsManifest.version}`;
|
||||
const offlineAssetsInclude = [ /\.dll$/, /\.pdb$/, /\.wasm/, /\.html/, /\.js$/, /\.json$/, /\.css$/, /\.woff$/, /\.png$/, /\.jpe?g$/, /\.gif$/, /\.ico$/, /\.blat$/, /\.dat$/, /\.webmanifest$/ ];
|
||||
const offlineAssetsExclude = [ /^service-worker\.js$/ ];
|
||||
const offlineAssetsInclude = [/\.dll$/, /\.pdb$/, /\.wasm/, /\.html/, /\.js$/, /\.json$/, /\.css$/, /\.woff$/, /\.png$/, /\.jpe?g$/, /\.gif$/, /\.ico$/, /\.blat$/, /\.dat$/, /\.webmanifest$/];
|
||||
// ИСКЛЮЧАЕМ также service-worker-assets.js
|
||||
const offlineAssetsExclude = [/^service-worker\.js$/, /\/service-worker-assets\.js$/];
|
||||
|
||||
// Replace with your base path if you are hosting on a subfolder. Ensure there is a trailing '/'.
|
||||
const base = "/";
|
||||
const baseUrl = new URL(base, self.origin);
|
||||
const manifestUrlList = self.assetsManifest.assets.map(asset => new URL(asset.url, baseUrl).href);
|
||||
|
||||
async function onInstall(event) {
|
||||
console.info('Service worker: Install');
|
||||
|
||||
self.skipWaiting();
|
||||
|
||||
// Fetch and cache all matching items from the assets manifest
|
||||
const assetsRequests = self.assetsManifest.assets
|
||||
.filter(asset => offlineAssetsInclude.some(pattern => pattern.test(asset.url)))
|
||||
.filter(asset => !offlineAssetsExclude.some(pattern => pattern.test(asset.url)))
|
||||
@@ -30,10 +27,14 @@ async function onInstall(event) {
|
||||
}
|
||||
|
||||
async function onActivate(event) {
|
||||
console.info('Service worker: Activate');
|
||||
|
||||
await self.clients.claim();
|
||||
|
||||
// НОВОЕ: Уведомляем все открытые вкладки о том, что новый SW активирован
|
||||
const clientsList = await self.clients.matchAll();
|
||||
clientsList.forEach(client => {
|
||||
client.postMessage({ type: 'SW_ACTIVATED', version: self.assetsManifest.version });
|
||||
});
|
||||
|
||||
// Delete unused caches
|
||||
const cacheKeys = await caches.keys();
|
||||
await Promise.all(cacheKeys
|
||||
@@ -42,13 +43,16 @@ async function onActivate(event) {
|
||||
}
|
||||
|
||||
async function onFetch(event) {
|
||||
// НОВОЕ: никогда не перехватываем файлы Service Worker
|
||||
const url = event.request.url;
|
||||
if (url.includes('/service-worker.js') || url.includes('/service-worker-assets.js')) {
|
||||
return fetch(event.request);
|
||||
}
|
||||
|
||||
let cachedResponse = null;
|
||||
if (event.request.method === 'GET') {
|
||||
// For all navigation requests, try to serve index.html from cache,
|
||||
// unless that request is for an offline resource.
|
||||
// If you need some URLs to be server-rendered, edit the following check to exclude those URLs
|
||||
const shouldServeIndexHtml = event.request.mode === 'navigate'
|
||||
&& !manifestUrlList.some(url => url === event.request.url);
|
||||
&& !manifestUrlList.some(u => u === event.request.url);
|
||||
|
||||
const request = shouldServeIndexHtml ? 'index.html' : event.request;
|
||||
const cache = await caches.open(cacheName);
|
||||
@@ -56,4 +60,4 @@ async function onFetch(event) {
|
||||
}
|
||||
|
||||
return cachedResponse || fetch(event.request);
|
||||
}
|
||||
}
|
||||
@@ -13,4 +13,5 @@ public enum TrackSearchType
|
||||
Album,
|
||||
Playlist,
|
||||
Track,
|
||||
MyPlaylists,
|
||||
}
|
||||
|
||||
@@ -17,6 +17,18 @@ public class YandexSearchResult
|
||||
[JsonPropertyName("playlists")]
|
||||
public List<YandexPlaylist>? Playlists { get; set; } = null;
|
||||
|
||||
/// <summary>
|
||||
/// Персональные плейлисты.
|
||||
/// </summary>
|
||||
[JsonPropertyName("personalPlaylists")]
|
||||
public List<YandexPlaylist>? PersonalPlaylists { get; set; } = null;
|
||||
|
||||
/// <summary>
|
||||
/// Плейлисты, которые понравились.
|
||||
/// </summary>
|
||||
[JsonPropertyName("likedPlaylists")]
|
||||
public List<YandexPlaylist>? LikedPlaylists { get; set; } = null;
|
||||
|
||||
/// <summary>
|
||||
/// Найденные исполнители.
|
||||
/// </summary>
|
||||
|
||||
Reference in New Issue
Block a user