Compare commits
10 Commits
362762a813
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
efe1c3c2dd | ||
|
|
9139d8ecfe | ||
|
|
38af6174fa | ||
|
|
2fe20c804a | ||
|
|
3c83a83396 | ||
|
|
14fcd7dff9 | ||
|
|
ecb12a7d4a | ||
|
|
2cd80c8082 | ||
|
|
78808ea525 | ||
|
|
d6da8460cc |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -363,3 +363,5 @@ MigrationBackup/
|
|||||||
FodyWeavers.xsd
|
FodyWeavers.xsd
|
||||||
/PlaylistShared.Pwa/wwwroot/appsettings.Development.json
|
/PlaylistShared.Pwa/wwwroot/appsettings.Development.json
|
||||||
/PlaylistShared.Api/appsettings.Development.json
|
/PlaylistShared.Api/appsettings.Development.json
|
||||||
|
/PlaylistShared.Pwa/ruvector.db
|
||||||
|
/PlaylistShared.Pwa/{
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using PlaylistShared.Api.Entities;
|
using PlaylistShared.Api.Entities;
|
||||||
using PlaylistShared.Api.Services;
|
using PlaylistShared.Api.Services;
|
||||||
using PlaylistShared.Shared;
|
using PlaylistShared.Shared;
|
||||||
using PlaylistShared.Shared.Auth;
|
using PlaylistShared.Shared.Auth;
|
||||||
using PlaylistShared.Shared.DTO;
|
using PlaylistShared.Shared.DTO;
|
||||||
|
|
||||||
|
namespace PlaylistShared.Api.Controllers;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/[controller]")]
|
[Route("api/[controller]")]
|
||||||
public class AccountController : ControllerBase
|
public class AccountController : ControllerBase
|
||||||
@@ -72,7 +75,7 @@ public class AccountController : ControllerBase
|
|||||||
[HttpPost("refresh-token")]
|
[HttpPost("refresh-token")]
|
||||||
public async Task<ActionResult<ApiResponse<LoginResponse>>> RefreshToken([FromBody] RefreshTokenRequest request)
|
public async Task<ActionResult<ApiResponse<LoginResponse>>> RefreshToken([FromBody] RefreshTokenRequest request)
|
||||||
{
|
{
|
||||||
var user = _userManager.Users.FirstOrDefault(u => u.RefreshToken == request.RefreshToken && u.RefreshTokenExpiryUtc > DateTime.UtcNow);
|
var user = await _userManager.Users.FirstOrDefaultAsync(u => u.RefreshToken == request.RefreshToken && u.RefreshTokenExpiryUtc > DateTime.UtcNow);
|
||||||
if (user == null)
|
if (user == null)
|
||||||
return Unauthorized(ApiResponse<LoginResponse>.Fail(new ErrorResponse { StatusCode = 401, Message = "Неверный или просроченный refresh token" }));
|
return Unauthorized(ApiResponse<LoginResponse>.Fail(new ErrorResponse { StatusCode = 401, Message = "Неверный или просроченный refresh token" }));
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using PlaylistShared.Api.Entities;
|
using PlaylistShared.Api.Entities;
|
||||||
@@ -18,47 +18,50 @@ public class AudioController : ControllerBase
|
|||||||
private readonly YandexMusicService _yandexService;
|
private readonly YandexMusicService _yandexService;
|
||||||
private readonly SharedPlaylistService _sharedService;
|
private readonly SharedPlaylistService _sharedService;
|
||||||
private readonly JwtService _jwtService;
|
private readonly JwtService _jwtService;
|
||||||
|
private readonly IHttpClientFactory _httpClientFactory;
|
||||||
|
|
||||||
public AudioController(
|
public AudioController(
|
||||||
UserManager<ApplicationUser> userManager,
|
UserManager<ApplicationUser> userManager,
|
||||||
YandexMusicService yandexService,
|
YandexMusicService yandexService,
|
||||||
SharedPlaylistService sharedService,
|
SharedPlaylistService sharedService,
|
||||||
JwtService jwtService)
|
JwtService jwtService,
|
||||||
|
IHttpClientFactory httpClientFactory)
|
||||||
{
|
{
|
||||||
_userManager = userManager;
|
_userManager = userManager;
|
||||||
_yandexService = yandexService;
|
_yandexService = yandexService;
|
||||||
_sharedService = sharedService;
|
_sharedService = sharedService;
|
||||||
_jwtService = jwtService;
|
_jwtService = jwtService;
|
||||||
|
_httpClientFactory = httpClientFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("play-token")]
|
||||||
|
[Authorize]
|
||||||
|
public IActionResult GetPlayToken()
|
||||||
|
{
|
||||||
|
var userId = User.GetUserId();
|
||||||
|
var token = _jwtService.CreatePlayToken(userId);
|
||||||
|
return Ok(ApiResponse<string>.Ok(token));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Потоковое воспроизведение трека из Яндекс.Музыки.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="trackId">ID трека (например, "21696942").</param>
|
|
||||||
/// <param name="access_token">gwt пользователя</param>
|
|
||||||
/// <param name="shared_id">ID расшаренного плейлиста</param>
|
|
||||||
[HttpGet("track/{trackId}")]
|
[HttpGet("track/{trackId}")]
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
public async Task<IActionResult> StreamTrack(string trackId, [FromQuery] string? access_token = null, [FromQuery] string? shared_id = null)
|
public async Task<IActionResult> StreamTrack(string trackId, [FromQuery] string? play_token = null, [FromQuery] string? shared_id = null)
|
||||||
{
|
{
|
||||||
var user = await GetUserFromToken(access_token);
|
var user = await GetUserFromPlayToken(play_token);
|
||||||
if (user == null || user.YandexAccessToken is null) user = await GetUserFromSharedPlaylistId(shared_id);
|
if (user == null || user.YandexAccessToken is null) user = await GetUserFromSharedPlaylistId(shared_id);
|
||||||
if (user == null) return Unauthorized();
|
if (user == null) return Unauthorized();
|
||||||
|
|
||||||
var streamUrl = await _yandexService.GetTrackFileUrlAsync(user, trackId);
|
var streamUrl = await _yandexService.GetTrackFileUrlAsync(user, trackId);
|
||||||
if (string.IsNullOrEmpty(streamUrl)) return NotFound();
|
if (string.IsNullOrEmpty(streamUrl)) return NotFound();
|
||||||
|
|
||||||
var httpClient = new HttpClient();
|
var httpClient = _httpClientFactory.CreateClient();
|
||||||
var request = new HttpRequestMessage(HttpMethod.Get, streamUrl);
|
var request = new HttpRequestMessage(HttpMethod.Get, streamUrl);
|
||||||
|
|
||||||
// Пробрасываем Range-заголовок клиента к Яндекс.Музыке
|
|
||||||
if (Request.Headers.ContainsKey("Range"))
|
if (Request.Headers.ContainsKey("Range"))
|
||||||
{
|
|
||||||
request.Headers.Add("Range", Request.Headers["Range"].ToString());
|
request.Headers.Add("Range", Request.Headers["Range"].ToString());
|
||||||
}
|
|
||||||
var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
|
var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
|
||||||
|
|
||||||
// Если Яндекс.Музыка поддерживает range, пробрасываем статус 206
|
|
||||||
Response.StatusCode = (int)response.StatusCode;
|
Response.StatusCode = (int)response.StatusCode;
|
||||||
Response.ContentType = response.Content.Headers.ContentType?.ToString() ?? "audio/mpeg";
|
Response.ContentType = response.Content.Headers.ContentType?.ToString() ?? "audio/mpeg";
|
||||||
|
|
||||||
@@ -75,9 +78,9 @@ public class AudioController : ControllerBase
|
|||||||
|
|
||||||
[HttpGet("track-info/{trackId}")]
|
[HttpGet("track-info/{trackId}")]
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
public async Task<ActionResult<ApiResponse<YandexTrack>>> GetTrackInfo(string trackId, [FromQuery] string? access_token = null, [FromQuery] string? shared_id = null)
|
public async Task<ActionResult<ApiResponse<YandexTrack>>> GetTrackInfo(string trackId, [FromQuery] string? play_token = null, [FromQuery] string? shared_id = null)
|
||||||
{
|
{
|
||||||
var user = await GetUserFromToken(access_token);
|
var user = await GetUserFromPlayToken(play_token);
|
||||||
if (user == null || user.YandexAccessToken is null) user = await GetUserFromSharedPlaylistId(shared_id);
|
if (user == null || user.YandexAccessToken is null) user = await GetUserFromSharedPlaylistId(shared_id);
|
||||||
if (user == null) return Unauthorized();
|
if (user == null) return Unauthorized();
|
||||||
|
|
||||||
@@ -99,30 +102,20 @@ public class AudioController : ControllerBase
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<ApplicationUser?> GetUserFromToken(string? token)
|
private async Task<ApplicationUser?> GetUserFromPlayToken(string? token)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(token)) return null;
|
if (string.IsNullOrEmpty(token)) return null;
|
||||||
|
var userId = _jwtService.ValidatePlayToken(token);
|
||||||
var principal = _jwtService.ValidateToken(token);
|
if (!userId.HasValue) return null;
|
||||||
if (principal == null) return null;
|
return await _userManager.FindByIdAsync(userId.Value.ToString());
|
||||||
|
|
||||||
var userId = principal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
|
||||||
if (string.IsNullOrEmpty(userId)) return null;
|
|
||||||
|
|
||||||
return await _userManager.FindByIdAsync(userId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<ApplicationUser?> GetUserFromSharedPlaylistId(string? sharedId)
|
private async Task<ApplicationUser?> GetUserFromSharedPlaylistId(string? sharedId)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(sharedId)) return null;
|
if (string.IsNullOrEmpty(sharedId)) return null;
|
||||||
|
|
||||||
var playlist = await _sharedService.GetEntityByTokenAsync(sharedId);
|
var playlist = await _sharedService.GetEntityByTokenAsync(sharedId);
|
||||||
|
|
||||||
if (playlist == null) return null;
|
if (playlist == null) return null;
|
||||||
|
|
||||||
if (!await _sharedService.CanPlayEveryoneAsync(playlist)) return null;
|
if (!await _sharedService.CanPlayEveryoneAsync(playlist)) return null;
|
||||||
|
|
||||||
return await _userManager.FindByIdAsync(playlist.CreatorUserId.ToString());
|
return await _userManager.FindByIdAsync(playlist.CreatorUserId.ToString());
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
67
PlaylistShared.Api/Controllers/OgController.cs
Normal file
67
PlaylistShared.Api/Controllers/OgController.cs
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using PlaylistShared.Api.Services;
|
||||||
|
|
||||||
|
namespace PlaylistShared.Api.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("shared")]
|
||||||
|
public class OgController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly SharedPlaylistService _sharedService;
|
||||||
|
private readonly string _clientBaseUrl;
|
||||||
|
|
||||||
|
public OgController(SharedPlaylistService sharedService, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
_sharedService = sharedService;
|
||||||
|
_clientBaseUrl = configuration["Client:BaseUrl"]?.TrimEnd('/') ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{token}")]
|
||||||
|
[Produces("text/html")]
|
||||||
|
public async Task<ContentResult> GetOgPage(string token)
|
||||||
|
{
|
||||||
|
var entity = await _sharedService.GetEntityByTokenAsync(token);
|
||||||
|
var pwaUrl = $"{_clientBaseUrl}/shared/{token}";
|
||||||
|
|
||||||
|
string title = entity?.Title ?? "Playlist Share";
|
||||||
|
string description = entity != null
|
||||||
|
? $"Слушайте плейлист «{entity.Title}» на Playlist Share"
|
||||||
|
: "Совместные плейлисты Яндекс.Музыки";
|
||||||
|
string imageUrl = !string.IsNullOrEmpty(entity?.CoverUrl) ? entity.CoverUrl : "";
|
||||||
|
|
||||||
|
var html = $"""
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>{HtmlEncode(title)}</title>
|
||||||
|
|
||||||
|
<meta property="og:type" content="music.playlist" />
|
||||||
|
<meta property="og:title" content="{HtmlEncode(title)}" />
|
||||||
|
<meta property="og:description" content="{HtmlEncode(description)}" />
|
||||||
|
<meta property="og:url" content="{HtmlEncode(pwaUrl)}" />
|
||||||
|
{(string.IsNullOrEmpty(imageUrl) ? "" : $"""<meta property="og:image" content="{HtmlEncode(imageUrl)}" />""")}
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:title" content="{HtmlEncode(title)}" />
|
||||||
|
<meta name="twitter:description" content="{HtmlEncode(description)}" />
|
||||||
|
{(string.IsNullOrEmpty(imageUrl) ? "" : $"""<meta name="twitter:image" content="{HtmlEncode(imageUrl)}" />""")}
|
||||||
|
|
||||||
|
<meta http-equiv="refresh" content="0;url={HtmlEncode(pwaUrl)}" />
|
||||||
|
<script>window.location.replace("{JsEncode(pwaUrl)}");</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<p>Перенаправление на <a href="{HtmlEncode(pwaUrl)}">{HtmlEncode(title)}</a>…</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
""";
|
||||||
|
|
||||||
|
return Content(html, "text/html; charset=utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string HtmlEncode(string s) =>
|
||||||
|
System.Web.HttpUtility.HtmlAttributeEncode(s);
|
||||||
|
|
||||||
|
private static string JsEncode(string s) =>
|
||||||
|
s.Replace("\\", "\\\\").Replace("'", "\\'").Replace("\"", "\\\"");
|
||||||
|
}
|
||||||
@@ -54,7 +54,7 @@ public class OpenIdController : ControllerBase
|
|||||||
var user = await _userManager.FindByLoginAsync(info.LoginProvider, info.ProviderKey);
|
var user = await _userManager.FindByLoginAsync(info.LoginProvider, info.ProviderKey);
|
||||||
if (user == null)
|
if (user == null)
|
||||||
{
|
{
|
||||||
user = await _userManager.FindByEmailAsync(email);
|
user = await _userManager.FindByEmailAsync(email!);
|
||||||
if (user == null)
|
if (user == null)
|
||||||
{
|
{
|
||||||
user = new ApplicationUser
|
user = new ApplicationUser
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using PlaylistShared.Api.Entities;
|
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.Enums;
|
|
||||||
using PlaylistShared.Shared.SharedPlaylist;
|
using PlaylistShared.Shared.SharedPlaylist;
|
||||||
using PlaylistShared.Shared.Yandex;
|
using PlaylistShared.Shared.Yandex;
|
||||||
|
|
||||||
@@ -19,18 +18,15 @@ public class PlaylistsController : ControllerBase
|
|||||||
private readonly UserManager<ApplicationUser> _userManager;
|
private readonly UserManager<ApplicationUser> _userManager;
|
||||||
private readonly SharedPlaylistService _sharedService;
|
private readonly SharedPlaylistService _sharedService;
|
||||||
private readonly YandexMusicService _yandexService;
|
private readonly YandexMusicService _yandexService;
|
||||||
private readonly YandexApiService _yandexApiService;
|
|
||||||
|
|
||||||
public PlaylistsController(
|
public PlaylistsController(
|
||||||
UserManager<ApplicationUser> userManager,
|
UserManager<ApplicationUser> userManager,
|
||||||
SharedPlaylistService sharedService,
|
SharedPlaylistService sharedService,
|
||||||
YandexMusicService yandexService,
|
YandexMusicService yandexService)
|
||||||
YandexApiService yandexApiService)
|
|
||||||
{
|
{
|
||||||
_userManager = userManager;
|
_userManager = userManager;
|
||||||
_sharedService = sharedService;
|
_sharedService = sharedService;
|
||||||
_yandexService = yandexService;
|
_yandexService = yandexService;
|
||||||
_yandexApiService = yandexApiService;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
@@ -40,20 +36,16 @@ public class PlaylistsController : ControllerBase
|
|||||||
var user = await _userManager.FindByIdAsync(userId.ToString());
|
var user = await _userManager.FindByIdAsync(userId.ToString());
|
||||||
if (user == null) return Unauthorized();
|
if (user == null) return Unauthorized();
|
||||||
|
|
||||||
var decryptedToken = _yandexApiService.DecryptToken(user.YandexAccessToken);
|
if (string.IsNullOrEmpty(user.YandexAccessToken))
|
||||||
if (string.IsNullOrEmpty(decryptedToken))
|
|
||||||
return BadRequest(ApiResponse<object>.Fail(new ErrorResponse { StatusCode = 400, Message = "Токен Яндекс.Музыки не установлен или недействителен" }));
|
return BadRequest(ApiResponse<object>.Fail(new ErrorResponse { StatusCode = 400, Message = "Токен Яндекс.Музыки не установлен или недействителен" }));
|
||||||
|
|
||||||
var authSuccess = await _yandexApiService.AuthAsync(decryptedToken);
|
List<YandexPlaylistShare> result;
|
||||||
if (!authSuccess)
|
try
|
||||||
return BadRequest(ApiResponse<object>.Fail(new ErrorResponse { StatusCode = 400, Message = "Не удалось авторизоваться в Яндекс.Музыке. Проверьте токен." }));
|
{
|
||||||
|
var (ownPlaylists, _) = await _yandexService.GetOwnFavoritesAsync(user);
|
||||||
var favorites = await _yandexApiService.Client.Api.Playlist.FavoritesAsync();
|
|
||||||
var ownPlaylists = favorites.Where(p => p.Owner.Uid == _yandexApiService.Client.Account.Uid).ToList();
|
|
||||||
|
|
||||||
var sharedPlaylists = await _sharedService.GetAllByUserAsync(userId);
|
var sharedPlaylists = await _sharedService.GetAllByUserAsync(userId);
|
||||||
|
|
||||||
var result = ownPlaylists.Select(p => new YandexPlaylistShare
|
result = (ownPlaylists ?? []).Select(p => new YandexPlaylistShare
|
||||||
{
|
{
|
||||||
Kind = p.Kind,
|
Kind = p.Kind,
|
||||||
OwnerUid = p.Owner.Uid,
|
OwnerUid = p.Owner.Uid,
|
||||||
@@ -63,6 +55,11 @@ public class PlaylistsController : ControllerBase
|
|||||||
IsShared = sharedPlaylists.Any(s => s.YandexPlaylistKind == p.Kind && s.YandexPlaylistOwnerUid == p.Owner.Uid),
|
IsShared = sharedPlaylists.Any(s => s.YandexPlaylistKind == p.Kind && s.YandexPlaylistOwnerUid == p.Owner.Uid),
|
||||||
ShareToken = sharedPlaylists.FirstOrDefault(s => s.YandexPlaylistKind == p.Kind && s.YandexPlaylistOwnerUid == p.Owner.Uid)?.ShareToken,
|
ShareToken = sharedPlaylists.FirstOrDefault(s => s.YandexPlaylistKind == p.Kind && s.YandexPlaylistOwnerUid == p.Owner.Uid)?.ShareToken,
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return BadRequest(ApiResponse<object>.Fail(new ErrorResponse { StatusCode = 400, Message = ex.Message }));
|
||||||
|
}
|
||||||
|
|
||||||
return Ok(ApiResponse<List<YandexPlaylistShare>>.Ok(result));
|
return Ok(ApiResponse<List<YandexPlaylistShare>>.Ok(result));
|
||||||
}
|
}
|
||||||
@@ -74,7 +71,6 @@ public class PlaylistsController : ControllerBase
|
|||||||
var user = await _userManager.FindByIdAsync(userId.ToString());
|
var user = await _userManager.FindByIdAsync(userId.ToString());
|
||||||
if (user == null) return Unauthorized();
|
if (user == null) return Unauthorized();
|
||||||
|
|
||||||
// Проверяем, что плейлист действительно принадлежит пользователю
|
|
||||||
var playlist = await _yandexService.GetPlaylistAsync(user, request.OwnerUid, request.Kind);
|
var playlist = await _yandexService.GetPlaylistAsync(user, request.OwnerUid, request.Kind);
|
||||||
if (playlist == null)
|
if (playlist == null)
|
||||||
return BadRequest(ApiResponse<object>.Fail(new ErrorResponse { StatusCode = 404, Message = "Плейлист не найден" }));
|
return BadRequest(ApiResponse<object>.Fail(new ErrorResponse { StatusCode = 404, Message = "Плейлист не найден" }));
|
||||||
@@ -86,10 +82,10 @@ public class PlaylistsController : ControllerBase
|
|||||||
YandexPlaylistOwnerUid = request.OwnerUid,
|
YandexPlaylistOwnerUid = request.OwnerUid,
|
||||||
Title = playlist.Title,
|
Title = playlist.Title,
|
||||||
Description = playlist.Description,
|
Description = playlist.Description,
|
||||||
ViewPermission = ViewPermission.Everyone,
|
ViewPermission = Shared.Enums.ViewPermission.Everyone,
|
||||||
PlayPermission = ViewPermission.Everyone,
|
PlayPermission = Shared.Enums.ViewPermission.Everyone,
|
||||||
AddPermission = EditPermission.AuthorizedOnly,
|
AddPermission = Shared.Enums.EditPermission.AuthorizedOnly,
|
||||||
RemovePermission = EditPermission.AddedByUserOnly,
|
RemovePermission = Shared.Enums.EditPermission.AddedByUserOnly,
|
||||||
};
|
};
|
||||||
|
|
||||||
var result = await _sharedService.CreateAsync(userId, dto);
|
var result = await _sharedService.CreateAsync(userId, dto);
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ using PlaylistShared.Api.Services;
|
|||||||
using PlaylistShared.Shared;
|
using PlaylistShared.Shared;
|
||||||
using PlaylistShared.Shared.SharedPlaylist;
|
using PlaylistShared.Shared.SharedPlaylist;
|
||||||
using PlaylistShared.Shared.Yandex;
|
using PlaylistShared.Shared.Yandex;
|
||||||
using YandexMusic.API.Models.Playlist;
|
|
||||||
|
namespace PlaylistShared.Api.Controllers;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/[controller]")]
|
[Route("api/[controller]")]
|
||||||
@@ -40,19 +41,16 @@ public class SharedPlaylistController : ControllerBase
|
|||||||
[HttpGet("{token}")]
|
[HttpGet("{token}")]
|
||||||
public async Task<ActionResult<ApiResponse<SharedPlaylistDto>>> GetByToken(string token)
|
public async Task<ActionResult<ApiResponse<SharedPlaylistDto>>> GetByToken(string token)
|
||||||
{
|
{
|
||||||
var playlist = await _sharedService.GetByTokenAsync(token);
|
var currentUserId = User.GetUserIdOrNull();
|
||||||
if (playlist == null)
|
|
||||||
|
var entity = await _sharedService.GetEntityByTokenAsync(token);
|
||||||
|
if (entity == null)
|
||||||
return NotFound(ApiResponse<SharedPlaylistDto>.Fail(new ErrorResponse { StatusCode = 404, Message = "Плейлист не найден" }));
|
return NotFound(ApiResponse<SharedPlaylistDto>.Fail(new ErrorResponse { StatusCode = 404, Message = "Плейлист не найден" }));
|
||||||
|
|
||||||
var currentUserId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
if (!await _sharedService.CanViewAsync(entity, currentUserId))
|
||||||
var userIdGuid = !string.IsNullOrEmpty(currentUserId) ? Guid.Parse(currentUserId) : (Guid?)null;
|
|
||||||
|
|
||||||
// Проверка прав просмотра (требует доступа к сущности)
|
|
||||||
var entity = await _sharedService.GetEntityByTokenAsync(token);
|
|
||||||
if (entity == null || !await _sharedService.CanViewAsync(entity, userIdGuid))
|
|
||||||
return Unauthorized(ApiResponse<SharedPlaylistDto>.Fail(new ErrorResponse { StatusCode = 401, Message = "Недостаточно прав" }));
|
return Unauthorized(ApiResponse<SharedPlaylistDto>.Fail(new ErrorResponse { StatusCode = 401, Message = "Недостаточно прав" }));
|
||||||
|
|
||||||
return Ok(ApiResponse<SharedPlaylistDto>.Ok(playlist));
|
return Ok(ApiResponse<SharedPlaylistDto>.Ok(_sharedService.MapToDto(entity)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /api/sharedplaylist/{token}/tracks
|
// GET /api/sharedplaylist/{token}/tracks
|
||||||
@@ -71,11 +69,10 @@ public class SharedPlaylistController : ControllerBase
|
|||||||
if (creator == null)
|
if (creator == null)
|
||||||
return StatusCode(500, ApiResponse<YandexPlaylistData>.Fail(new ErrorResponse { StatusCode = 500, Message = "Владелец плейлиста не найден" }));
|
return StatusCode(500, ApiResponse<YandexPlaylistData>.Fail(new ErrorResponse { StatusCode = 500, Message = "Владелец плейлиста не найден" }));
|
||||||
|
|
||||||
var yandexPlaylist = await _yandexService.GetPlaylistAsync(creator, playlist.YandexPlaylistOwnerUid, playlist.YandexPlaylistKind);
|
var dto = await _yandexService.GetPlaylistDataAsync(creator, playlist.YandexPlaylistOwnerUid, playlist.YandexPlaylistKind);
|
||||||
if (yandexPlaylist == null)
|
if (dto == null)
|
||||||
return NotFound(ApiResponse<YandexPlaylistData>.Fail(new ErrorResponse { StatusCode = 404, Message = "Плейлист не найден в Яндекс.Музыке" }));
|
return NotFound(ApiResponse<YandexPlaylistData>.Fail(new ErrorResponse { StatusCode = 404, Message = "Плейлист не найден в Яндекс.Музыке" }));
|
||||||
|
|
||||||
var dto = MapToYandexPlaylistData(yandexPlaylist);
|
|
||||||
return Ok(ApiResponse<YandexPlaylistData>.Ok(dto));
|
return Ok(ApiResponse<YandexPlaylistData>.Ok(dto));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,6 +126,22 @@ public class SharedPlaylistController : ControllerBase
|
|||||||
return Ok(ApiResponse<object>.Ok(new { message = "Треки добавлены" }));
|
return Ok(ApiResponse<object>.Ok(new { message = "Треки добавлены" }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GET /api/sharedplaylist/{token}/additions
|
||||||
|
[HttpGet("{token}/additions")]
|
||||||
|
public async Task<ActionResult<ApiResponse<Dictionary<string, string?>>>> GetAdditions(string token)
|
||||||
|
{
|
||||||
|
var currentUserId = User.GetUserIdOrNull();
|
||||||
|
var playlist = await _sharedService.GetEntityByTokenAsync(token);
|
||||||
|
if (playlist == null)
|
||||||
|
return NotFound(ApiResponse<Dictionary<string, string?>>.Fail(new ErrorResponse { StatusCode = 404, Message = "Плейлист не найден" }));
|
||||||
|
|
||||||
|
if (!await _sharedService.CanViewAsync(playlist, currentUserId))
|
||||||
|
return Unauthorized();
|
||||||
|
|
||||||
|
var additions = await _trackAdditionLogService.GetAdditionUserNamesAsync(playlist.Id);
|
||||||
|
return Ok(ApiResponse<Dictionary<string, string?>>.Ok(additions));
|
||||||
|
}
|
||||||
|
|
||||||
// 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] UpdateTrackListRequest request)
|
public async Task<ActionResult<ApiResponse<object>>> RemoveTracks(string token, [FromBody] UpdateTrackListRequest request)
|
||||||
@@ -164,26 +177,4 @@ public class SharedPlaylistController : ControllerBase
|
|||||||
return Ok(ApiResponse<object>.Ok(new { message = "Треки удалены" }));
|
return Ok(ApiResponse<object>.Ok(new { message = "Треки удалены" }));
|
||||||
}
|
}
|
||||||
|
|
||||||
private YandexPlaylistData MapToYandexPlaylistData(YPlaylist playlist)
|
|
||||||
{
|
|
||||||
return new YandexPlaylistData
|
|
||||||
{
|
|
||||||
Title = playlist.Title,
|
|
||||||
Description = playlist.Description,
|
|
||||||
Tracks = playlist.Tracks.Select(t => new YandexTrack
|
|
||||||
{
|
|
||||||
TrackId = t.Track.Id,
|
|
||||||
Title = t.Track.Title,
|
|
||||||
Artists = t.Track.Artists.Select(t => new YandexArtist()
|
|
||||||
{
|
|
||||||
Id = t.Id,
|
|
||||||
Name = t.Name,
|
|
||||||
CoverUrl = t.Cover.GetUrl(),
|
|
||||||
Description = t.Description?.Text ?? string.Empty,
|
|
||||||
}).ToList(),
|
|
||||||
DurationMs = (int)(t.Track?.DurationMs ?? 0),
|
|
||||||
CoverUri = t.Track?.CoverUri ?? ""
|
|
||||||
}).ToList() ?? new List<YandexTrack>()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using PlaylistShared.Api.Entities;
|
using PlaylistShared.Api.Entities;
|
||||||
@@ -31,11 +31,7 @@ public class YandexAccountController : ControllerBase
|
|||||||
var user = await _userManager.FindByIdAsync(userId.ToString());
|
var user = await _userManager.FindByIdAsync(userId.ToString());
|
||||||
if (user == null) return Unauthorized();
|
if (user == null) return Unauthorized();
|
||||||
|
|
||||||
user.YandexAccessToken = _yandexService.Service.EncryptToken(request.Token);
|
await SaveYandexTokenAsync(user, request.Token);
|
||||||
// Не храним refresh-токен, так как пользователь вводит только access-токен
|
|
||||||
user.YandexTokenExpiryUtc = DateTime.UtcNow.AddMonths(1); // условно, т.к. срок жизни токена неизвестен
|
|
||||||
await _userManager.UpdateAsync(user);
|
|
||||||
|
|
||||||
return Ok(ApiResponse<object>.Ok(new { message = "Токен сохранён" }));
|
return Ok(ApiResponse<object>.Ok(new { message = "Токен сохранён" }));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,7 +61,6 @@ public class YandexAccountController : ControllerBase
|
|||||||
if (user == null) return Unauthorized();
|
if (user == null) return Unauthorized();
|
||||||
|
|
||||||
var qr = await _yandexService.GetQrOrGenerate(user);
|
var qr = await _yandexService.GetQrOrGenerate(user);
|
||||||
|
|
||||||
return Ok(ApiResponse<YandexAuthQr>.Ok(qr));
|
return Ok(ApiResponse<YandexAuthQr>.Ok(qr));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,10 +76,16 @@ public class YandexAccountController : ControllerBase
|
|||||||
|
|
||||||
if (checkResult.Status == Shared.Enums.YandexAuthQrStatus.Authorized)
|
if (checkResult.Status == Shared.Enums.YandexAuthQrStatus.Authorized)
|
||||||
{
|
{
|
||||||
await SetToken(new() { Token = _yandexService.Service.Client.AuthStorage.Token });
|
await SaveYandexTokenAsync(user, _yandexService.Service.Client.AuthStorage.Token);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Ok(ApiResponse<YandexAuthQrCheck>.Ok(checkResult));
|
return Ok(ApiResponse<YandexAuthQrCheck>.Ok(checkResult));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task SaveYandexTokenAsync(ApplicationUser user, string token)
|
||||||
|
{
|
||||||
|
user.YandexAccessToken = _yandexService.Service.EncryptToken(token);
|
||||||
|
user.YandexTokenExpiryUtc = DateTime.UtcNow.AddMonths(1);
|
||||||
|
await _userManager.UpdateAsync(user);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -28,13 +28,13 @@ public class YandexSearchController : ControllerBase
|
|||||||
|
|
||||||
[HttpGet("search")]
|
[HttpGet("search")]
|
||||||
public async Task<ActionResult<ApiResponse<YandexSearchResult>>> SearchQuery(
|
public async Task<ActionResult<ApiResponse<YandexSearchResult>>> SearchQuery(
|
||||||
[FromQuery] string query,
|
[FromQuery] string query = "",
|
||||||
[FromQuery] int limit = 20,
|
[FromQuery] int limit = 40,
|
||||||
[FromQuery] TrackSearchType searchType = TrackSearchType.All,
|
[FromQuery] TrackSearchType searchType = TrackSearchType.All,
|
||||||
[FromQuery] bool byId = false,
|
[FromQuery] bool byId = false,
|
||||||
[FromQuery] string? shared_id = null)
|
[FromQuery] string? shared_id = null)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(query))
|
if (string.IsNullOrWhiteSpace(query) && searchType != TrackSearchType.MyPlaylists)
|
||||||
return BadRequest(ApiResponse<YandexSearchResult>.Fail(new ErrorResponse
|
return BadRequest(ApiResponse<YandexSearchResult>.Fail(new ErrorResponse
|
||||||
{
|
{
|
||||||
StatusCode = 400,
|
StatusCode = 400,
|
||||||
@@ -46,6 +46,8 @@ public class YandexSearchController : ControllerBase
|
|||||||
if (userId.HasValue)
|
if (userId.HasValue)
|
||||||
user = await _userManager.FindByIdAsync(userId.Value.ToString());
|
user = await _userManager.FindByIdAsync(userId.Value.ToString());
|
||||||
|
|
||||||
|
var byShareId = false;
|
||||||
|
|
||||||
// Если нет пользователя или у него нет токена, пробуем через shared_id
|
// Если нет пользователя или у него нет токена, пробуем через shared_id
|
||||||
if (user == null || string.IsNullOrEmpty(user.YandexAccessToken))
|
if (user == null || string.IsNullOrEmpty(user.YandexAccessToken))
|
||||||
{
|
{
|
||||||
@@ -61,6 +63,8 @@ public class YandexSearchController : ControllerBase
|
|||||||
var owner = await _userManager.FindByIdAsync(playlist.CreatorUserId.ToString());
|
var owner = await _userManager.FindByIdAsync(playlist.CreatorUserId.ToString());
|
||||||
if (owner == null) return StatusCode(500, "Не удалось найти владельца плейлиста.");
|
if (owner == null) return StatusCode(500, "Не удалось найти владельца плейлиста.");
|
||||||
user = owner;
|
user = owner;
|
||||||
|
|
||||||
|
byShareId = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(user.YandexAccessToken))
|
if (string.IsNullOrEmpty(user.YandexAccessToken))
|
||||||
@@ -74,7 +78,16 @@ public class YandexSearchController : ControllerBase
|
|||||||
|
|
||||||
if (byId)
|
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
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -90,6 +90,12 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser, IdentityR
|
|||||||
.OnDelete(DeleteBehavior.Restrict);
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
builder.Entity<ApplicationUser>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasIndex(e => e.RefreshToken)
|
||||||
|
.HasDatabaseName("IX_AspNetUsers_RefreshToken");
|
||||||
|
});
|
||||||
|
|
||||||
builder.Entity<FavoritePlaylist>(entity =>
|
builder.Entity<FavoritePlaylist>(entity =>
|
||||||
{
|
{
|
||||||
entity.HasKey(e => new { e.UserId, e.SharedPlaylistId });
|
entity.HasKey(e => new { e.UserId, e.SharedPlaylistId });
|
||||||
@@ -128,10 +134,12 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser, IdentityR
|
|||||||
entity.Property(e => e.TrackId)
|
entity.Property(e => e.TrackId)
|
||||||
.HasMaxLength(100)
|
.HasMaxLength(100)
|
||||||
.IsRequired(false);
|
.IsRequired(false);
|
||||||
entity.Property(e => e.CsfrToken)
|
entity.Property(e => e.CsrfToken)
|
||||||
|
.HasColumnName("CsfrToken")
|
||||||
.HasMaxLength(200)
|
.HasMaxLength(200)
|
||||||
.IsRequired(false);
|
.IsRequired(false);
|
||||||
entity.Property(e => e.HeaderCsfrToken)
|
entity.Property(e => e.HeaderCsrfToken)
|
||||||
|
.HasColumnName("HeaderCsfrToken")
|
||||||
.HasMaxLength(200)
|
.HasMaxLength(200)
|
||||||
.IsRequired(false);
|
.IsRequired(false);
|
||||||
entity.Property(e => e.HeaderProcessId)
|
entity.Property(e => e.HeaderProcessId)
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
namespace PlaylistShared.Api.Entities;
|
namespace PlaylistShared.Api.Entities;
|
||||||
|
|
||||||
public class YandexAuthSession
|
public class YandexAuthSession
|
||||||
{
|
{
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
public Guid? UserId { get; set; }
|
public Guid? UserId { get; set; }
|
||||||
public string QrCodeUrl { get; set; }
|
public string QrCodeUrl { get; set; } = string.Empty;
|
||||||
public string SerializedCookies { get; set; }
|
public string SerializedCookies { get; set; } = string.Empty;
|
||||||
public DateTime CreatedAt { get; set; }
|
public DateTime CreatedAt { get; set; }
|
||||||
public DateTime? ConfirmedAt { get; set; }
|
public DateTime? ConfirmedAt { get; set; }
|
||||||
public bool IsConfirmed { get; set; }
|
public bool IsConfirmed { get; set; }
|
||||||
public string? TrackId { get; set; }
|
public string? TrackId { get; set; }
|
||||||
public string? CsfrToken { get; set; }
|
public string? CsrfToken { get; set; }
|
||||||
public string? HeaderProcessId { get; set; }
|
public string? HeaderProcessId { get; set; }
|
||||||
public string? HeaderCsfrToken { get; set; }
|
public string? HeaderCsrfToken { get; set; }
|
||||||
|
|
||||||
public ApplicationUser? User { get; set; }
|
public ApplicationUser? User { get; set; }
|
||||||
}
|
}
|
||||||
@@ -10,24 +10,24 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.6" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.7" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.6" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.7" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" Version="10.0.6" />
|
<PackageReference Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" Version="10.0.7" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.6" />
|
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.7" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Identity.UI" Version="10.0.6" />
|
<PackageReference Include="Microsoft.AspNetCore.Identity.UI" Version="10.0.7" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.6" />
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.7" />
|
||||||
<PackageReference Include="AspNet.Security.OAuth.Yandex" Version="10.0.0" />
|
<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>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.6" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.7" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Caching.SqlServer" Version="10.0.6" />
|
<PackageReference Include="Microsoft.Extensions.Caching.SqlServer" Version="10.0.7" />
|
||||||
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.23.0" />
|
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.23.0" />
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore.Swagger" Version="10.1.7" />
|
<PackageReference Include="Swashbuckle.AspNetCore.Swagger" Version="10.1.7" />
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.1.7" />
|
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.1.7" />
|
||||||
<PackageReference Include="YandexMusic" Version="0.0.15" />
|
<PackageReference Include="YandexMusic" Version="0.0.16" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
|
||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
using Microsoft.AspNetCore.DataProtection;
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
|
using Microsoft.AspNetCore.HttpOverrides;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
@@ -51,6 +52,14 @@ public class Program
|
|||||||
options.Cookie.HttpOnly = true;
|
options.Cookie.HttpOnly = true;
|
||||||
options.Cookie.IsEssential = true;
|
options.Cookie.IsEssential = true;
|
||||||
options.Cookie.SameSite = SameSiteMode.Lax;
|
options.Cookie.SameSite = SameSiteMode.Lax;
|
||||||
|
options.Cookie.MaxAge = TimeSpan.FromDays(30); // persistent across browser restarts
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||||
|
{
|
||||||
|
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
|
||||||
|
options.KnownNetworks.Clear(); // trust all proxies in Docker network
|
||||||
|
options.KnownProxies.Clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
// JWT
|
// JWT
|
||||||
@@ -111,7 +120,7 @@ public class Program
|
|||||||
{
|
{
|
||||||
options.AddPolicy("Production", policy =>
|
options.AddPolicy("Production", policy =>
|
||||||
{
|
{
|
||||||
policy.WithOrigins(builder.Configuration.GetSection("Cors:Origins").Get<string[]>())
|
policy.WithOrigins(builder.Configuration.GetSection("Cors:Origins").Get<string[]>() ?? [])
|
||||||
.AllowAnyMethod()
|
.AllowAnyMethod()
|
||||||
.AllowAnyHeader()
|
.AllowAnyHeader()
|
||||||
.AllowCredentials();
|
.AllowCredentials();
|
||||||
@@ -123,18 +132,13 @@ public class Program
|
|||||||
{
|
{
|
||||||
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
|
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
|
||||||
});
|
});
|
||||||
builder.Services.AddEndpointsApiExplorer();
|
|
||||||
builder.Services.AddSwaggerGen();
|
|
||||||
|
|
||||||
builder.Services.AddOpenApi();
|
builder.Services.AddOpenApi();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
app.MapOpenApi();
|
app.MapOpenApi();
|
||||||
|
|
||||||
app.UseSwagger();
|
app.UseForwardedHeaders();
|
||||||
app.UseSwaggerUI();
|
|
||||||
|
|
||||||
app.UseCors("Production");
|
app.UseCors("Production");
|
||||||
|
|
||||||
if (!app.Environment.IsDevelopment())
|
if (!app.Environment.IsDevelopment())
|
||||||
|
|||||||
@@ -90,10 +90,10 @@ public class FavoritesService
|
|||||||
Creator = sp.Creator != null ? new Shared.Auth.ApplicationUserDto
|
Creator = sp.Creator != null ? new Shared.Auth.ApplicationUserDto
|
||||||
{
|
{
|
||||||
Id = sp.Creator.Id,
|
Id = sp.Creator.Id,
|
||||||
UserName = sp.Creator.UserName,
|
UserName = sp.Creator.UserName ?? "",
|
||||||
Email = sp.Creator.Email,
|
Email = sp.Creator.Email ?? "",
|
||||||
YandexId = sp.Creator.YandexId,
|
YandexId = sp.Creator.YandexId,
|
||||||
DisplayName = sp.Creator.UserName
|
DisplayName = sp.Creator.UserName ?? ""
|
||||||
} : null
|
} : null
|
||||||
}).ToList();
|
}).ToList();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
using PlaylistShared.Api.Entities;
|
using PlaylistShared.Api.Entities;
|
||||||
using System.IdentityModel.Tokens.Jwt;
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
@@ -11,11 +12,15 @@ public class JwtService
|
|||||||
{
|
{
|
||||||
private readonly IConfiguration _configuration;
|
private readonly IConfiguration _configuration;
|
||||||
private readonly UserManager<ApplicationUser> _userManager;
|
private readonly UserManager<ApplicationUser> _userManager;
|
||||||
|
private readonly ITimeLimitedDataProtector _playTokenProtector;
|
||||||
|
|
||||||
public JwtService(IConfiguration configuration, UserManager<ApplicationUser> userManager)
|
public JwtService(IConfiguration configuration, UserManager<ApplicationUser> userManager, IDataProtectionProvider dataProtectionProvider)
|
||||||
{
|
{
|
||||||
_configuration = configuration;
|
_configuration = configuration;
|
||||||
_userManager = userManager;
|
_userManager = userManager;
|
||||||
|
_playTokenProtector = dataProtectionProvider
|
||||||
|
.CreateProtector("AudioPlayToken")
|
||||||
|
.ToTimeLimitedDataProtector();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<(string Token, string RefreshToken, DateTime Expiration)> GenerateTokenAsync(ApplicationUser user)
|
public async Task<(string Token, string RefreshToken, DateTime Expiration)> GenerateTokenAsync(ApplicationUser user)
|
||||||
@@ -71,4 +76,20 @@ public class JwtService
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public string CreatePlayToken(Guid userId) =>
|
||||||
|
_playTokenProtector.Protect(userId.ToString(), TimeSpan.FromMinutes(5));
|
||||||
|
|
||||||
|
public Guid? ValidatePlayToken(string token)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var userId = _playTokenProtector.Unprotect(token);
|
||||||
|
return Guid.Parse(userId);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -42,14 +42,6 @@ public class SharedPlaylistService
|
|||||||
return MapToDto(entity);
|
return MapToDto(entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<SharedPlaylistDto?> GetByTokenAsync(string token)
|
|
||||||
{
|
|
||||||
var entity = await _db.SharedPlaylists
|
|
||||||
.Include(sp => sp.Creator)
|
|
||||||
.FirstOrDefaultAsync(sp => sp.ShareToken == token && !sp.IsDeleted);
|
|
||||||
return entity == null ? null : MapToDto(entity);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<SharedPlaylist?> GetEntityByTokenAsync(string token)
|
public async Task<SharedPlaylist?> GetEntityByTokenAsync(string token)
|
||||||
{
|
{
|
||||||
return await _db.SharedPlaylists
|
return await _db.SharedPlaylists
|
||||||
@@ -142,8 +134,7 @@ public class SharedPlaylistService
|
|||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ручное маппинг сущности в DTO
|
public SharedPlaylistDto MapToDto(SharedPlaylist entity)
|
||||||
private SharedPlaylistDto MapToDto(SharedPlaylist entity)
|
|
||||||
{
|
{
|
||||||
return new SharedPlaylistDto
|
return new SharedPlaylistDto
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -43,4 +43,17 @@ public class TrackAdditionLogService
|
|||||||
_db.TrackAdditionLogs.RemoveRange(logs);
|
_db.TrackAdditionLogs.RemoveRange(logs);
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<Dictionary<string, string?>> GetAdditionUserNamesAsync(Guid sharedPlaylistId)
|
||||||
|
{
|
||||||
|
var rows = await _db.TrackAdditionLogs
|
||||||
|
.Where(l => l.SharedPlaylistId == sharedPlaylistId)
|
||||||
|
.Include(l => l.AddedByUser)
|
||||||
|
.OrderByDescending(l => l.AddedAtUtc)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
return rows
|
||||||
|
.GroupBy(l => l.TrackId)
|
||||||
|
.ToDictionary(g => g.Key, g => g.First().AddedByUser?.UserName);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -80,7 +80,7 @@ public class YandexApiService : IDisposable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="encryptedToken"></param>
|
/// <param name="encryptedToken"></param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
public string DecryptToken(string encryptedToken)
|
public string? DecryptToken(string encryptedToken)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
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.Yandex;
|
using PlaylistShared.Shared.Yandex;
|
||||||
@@ -24,10 +24,10 @@ public class YandexAuthService
|
|||||||
|
|
||||||
internal async Task<YandexAuthQr> GetQrOrGenerate(ApplicationUser user)
|
internal async Task<YandexAuthQr> GetQrOrGenerate(ApplicationUser user)
|
||||||
{
|
{
|
||||||
var existingSession = _dbContext.YandexAuthSessions
|
var existingSession = await _dbContext.YandexAuthSessions
|
||||||
.Where(s => s.UserId == user.Id && !s.IsConfirmed && s.CreatedAt > DateTime.UtcNow.AddMinutes(-5))
|
.Where(s => s.UserId == user.Id && !s.IsConfirmed && s.CreatedAt > DateTime.UtcNow.AddMinutes(-5))
|
||||||
.OrderByDescending(s => s.CreatedAt)
|
.OrderByDescending(s => s.CreatedAt)
|
||||||
.FirstOrDefault();
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
if (existingSession != null)
|
if (existingSession != null)
|
||||||
{
|
{
|
||||||
@@ -44,15 +44,15 @@ public class YandexAuthService
|
|||||||
internal async Task<YandexAuthQr> GenerateQrAsync(ApplicationUser user)
|
internal async Task<YandexAuthQr> GenerateQrAsync(ApplicationUser user)
|
||||||
{
|
{
|
||||||
var qr = await Api.Passport.GetAuthQRLinkAsync();
|
var qr = await Api.Passport.GetAuthQRLinkAsync();
|
||||||
var trackId = Service.Client.AuthStorage.AuthToken.TrackId;
|
var trackId = Service.Client.AuthStorage.AuthToken?.TrackId;
|
||||||
var csfrToken = Service.Client.AuthStorage.AuthToken.CsfrToken;
|
var csrfToken = Service.Client.AuthStorage.AuthToken?.CsfrToken;
|
||||||
var headerProcessUuid = Service.Client.AuthStorage.HeaderToken.ProcessUuid;
|
var headerProcessUuid = Service.Client.AuthStorage.HeaderToken?.ProcessUuid;
|
||||||
var headerCsfrToken = Service.Client.AuthStorage.HeaderToken.CsfrToken;
|
var headerCsrfToken = Service.Client.AuthStorage.HeaderToken?.CsfrToken;
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(qr))
|
if (string.IsNullOrEmpty(qr))
|
||||||
throw new Exception("Не удалось получить QR-ссылку");
|
throw new Exception("Не удалось получить QR-ссылку");
|
||||||
|
|
||||||
var cookiesJson = SerializeCookies(_apiService.CookieContainer);
|
var cookiesJson = _apiService.EncryptToken(SerializeCookies(_apiService.CookieContainer));
|
||||||
|
|
||||||
var session = new YandexAuthSession
|
var session = new YandexAuthSession
|
||||||
{
|
{
|
||||||
@@ -62,10 +62,9 @@ public class YandexAuthService
|
|||||||
CreatedAt = DateTime.UtcNow,
|
CreatedAt = DateTime.UtcNow,
|
||||||
IsConfirmed = false,
|
IsConfirmed = false,
|
||||||
TrackId = trackId,
|
TrackId = trackId,
|
||||||
CsfrToken = csfrToken,
|
CsrfToken = csrfToken,
|
||||||
HeaderCsfrToken = headerCsfrToken,
|
HeaderCsrfToken = headerCsrfToken,
|
||||||
HeaderProcessId = headerProcessUuid,
|
HeaderProcessId = headerProcessUuid,
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
_dbContext.YandexAuthSessions.Add(session);
|
_dbContext.YandexAuthSessions.Add(session);
|
||||||
@@ -83,16 +82,21 @@ public class YandexAuthService
|
|||||||
var session = await _dbContext.YandexAuthSessions.FindAsync(sessionId);
|
var session = await _dbContext.YandexAuthSessions.FindAsync(sessionId);
|
||||||
if (session == null) return null;
|
if (session == null) return null;
|
||||||
|
|
||||||
RestoreCookies(Service.CookieContainer, session.SerializedCookies);
|
var decryptedCookies = _apiService.DecryptToken(session.SerializedCookies);
|
||||||
|
if (decryptedCookies == null) return null;
|
||||||
|
|
||||||
|
RestoreCookies(Service.CookieContainer, decryptedCookies);
|
||||||
if (Service.Client.AuthStorage.AuthToken is null)
|
if (Service.Client.AuthStorage.AuthToken is null)
|
||||||
{
|
{
|
||||||
Service.Client.AuthStorage.AuthToken = new();
|
Service.Client.AuthStorage.AuthToken = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
Service.Client.AuthStorage.AuthToken.CsfrToken = session?.CsfrToken ?? "";
|
Service.Client.AuthStorage.AuthToken.CsfrToken = session.CsrfToken ?? "";
|
||||||
Service.Client.AuthStorage.AuthToken.TrackId = session?.TrackId ?? "";
|
Service.Client.AuthStorage.AuthToken.TrackId = session.TrackId ?? "";
|
||||||
Service.Client.AuthStorage.HeaderToken.CsfrToken = session?.HeaderCsfrToken ?? "";
|
if (Service.Client.AuthStorage.HeaderToken is null)
|
||||||
Service.Client.AuthStorage.HeaderToken.ProcessUuid = session?.HeaderProcessId ?? "";
|
Service.Client.AuthStorage.HeaderToken = new();
|
||||||
|
Service.Client.AuthStorage.HeaderToken.CsfrToken = session.HeaderCsrfToken ?? "";
|
||||||
|
Service.Client.AuthStorage.HeaderToken.ProcessUuid = session.HeaderProcessId ?? "";
|
||||||
|
|
||||||
var status = await Api.Passport.CheckQRStatusAsync();
|
var status = await Api.Passport.CheckQRStatusAsync();
|
||||||
|
|
||||||
@@ -100,53 +104,45 @@ public class YandexAuthService
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var auth = await Api.Passport.AuthorizeByQRAsync();
|
await Api.Passport.AuthorizeByQRAsync();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch
|
||||||
{
|
{
|
||||||
return new() { Status = Shared.Enums.YandexAuthQrStatus.Error, };
|
return new() { Status = Shared.Enums.YandexAuthQrStatus.Error };
|
||||||
}
|
}
|
||||||
|
|
||||||
_dbContext.YandexAuthSessions.Where(t => t.UserId == session.UserId).ExecuteDelete();
|
await _dbContext.YandexAuthSessions
|
||||||
_dbContext.SaveChanges();
|
.Where(t => t.UserId == session.UserId)
|
||||||
|
.ExecuteDeleteAsync();
|
||||||
|
await _dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
return new() { Status = Shared.Enums.YandexAuthQrStatus.Authorized, };
|
return new() { Status = Shared.Enums.YandexAuthQrStatus.Authorized };
|
||||||
}
|
}
|
||||||
|
|
||||||
return new()
|
return new() { Status = Shared.Enums.YandexAuthQrStatus.Pending };
|
||||||
{
|
|
||||||
Status = Shared.Enums.YandexAuthQrStatus.Pending,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private string SerializeCookies(CookieContainer container)
|
private string SerializeCookies(CookieContainer container)
|
||||||
{
|
{
|
||||||
var allCookies = new List<object>();
|
var allCookies = new List<object>();
|
||||||
|
foreach (Cookie cookie in container.GetAllCookies())
|
||||||
var cookies = container.GetAllCookies();
|
|
||||||
foreach (Cookie cookie in cookies)
|
|
||||||
{
|
|
||||||
allCookies.Add(new { cookie.Name, cookie.Value, cookie.Domain, cookie.Path });
|
allCookies.Add(new { cookie.Name, cookie.Value, cookie.Domain, cookie.Path });
|
||||||
}
|
|
||||||
|
|
||||||
return JsonSerializer.Serialize(allCookies);
|
return JsonSerializer.Serialize(allCookies);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void RestoreCookies(CookieContainer container, string serializedCookies)
|
private void RestoreCookies(CookieContainer container, string serializedCookies)
|
||||||
{
|
{
|
||||||
var cookies = JsonSerializer.Deserialize<List<CookieData>>(serializedCookies);
|
var cookies = JsonSerializer.Deserialize<List<CookieData>>(serializedCookies);
|
||||||
|
if (cookies == null) return;
|
||||||
foreach (var c in cookies)
|
foreach (var c in cookies)
|
||||||
{
|
|
||||||
container.Add(new Cookie(c.Name, c.Value, c.Path, c.Domain));
|
container.Add(new Cookie(c.Name, c.Value, c.Path, c.Domain));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private class CookieData
|
private class CookieData
|
||||||
{
|
{
|
||||||
public string Name { get; set; }
|
public string Name { get; set; } = string.Empty;
|
||||||
public string Value { get; set; }
|
public string Value { get; set; } = string.Empty;
|
||||||
public string Domain { get; set; }
|
public string Domain { get; set; } = string.Empty;
|
||||||
public string Path { get; set; }
|
public string Path { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -35,6 +35,21 @@ public class YandexMusicService
|
|||||||
return await Api.Playlist.GetAsync(ownerUid, kind);
|
return await Api.Playlist.GetAsync(ownerUid, kind);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<YandexPlaylistData?> GetPlaylistDataAsync(ApplicationUser user, string ownerUid, string kind)
|
||||||
|
{
|
||||||
|
var playlist = await GetPlaylistAsync(user, ownerUid, kind);
|
||||||
|
return playlist == null ? null : MapToPlaylistData(playlist);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(List<YPlaylist>? OwnPlaylists, string? AccountUid)> GetOwnFavoritesAsync(ApplicationUser user)
|
||||||
|
{
|
||||||
|
await AuthorizeIfNot(user);
|
||||||
|
var favorites = await Api.Playlist.FavoritesAsync();
|
||||||
|
var accountUid = _yandexApiService.Client.Account?.Uid;
|
||||||
|
var ownPlaylists = favorites?.Where(p => p.Owner?.Uid == accountUid).ToList();
|
||||||
|
return (ownPlaylists, accountUid);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<YPlaylist?> CreatePlaylistAsync(ApplicationUser user, string title)
|
public async Task<YPlaylist?> CreatePlaylistAsync(ApplicationUser user, string title)
|
||||||
{
|
{
|
||||||
await AuthorizeIfNot(user);
|
await AuthorizeIfNot(user);
|
||||||
@@ -161,11 +176,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(
|
public async Task<YandexSearchResult> SearchTracksByIdAsync(
|
||||||
ApplicationUser user,
|
ApplicationUser user,
|
||||||
string id,
|
string id,
|
||||||
TrackSearchType searchType,
|
TrackSearchType searchType
|
||||||
int limit = 20
|
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
YandexSearchResult result = new();
|
YandexSearchResult result = new();
|
||||||
@@ -254,8 +314,8 @@ public class YandexMusicService
|
|||||||
TrackCount = p.TrackCount,
|
TrackCount = p.TrackCount,
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
|
var allTraks = await artist.Artist.GetAllTracksAsync();
|
||||||
result.Tracks = artist.PopularTracks.Select(t => new YandexTrack
|
result.Tracks = allTraks?.Select(t => new YandexTrack
|
||||||
{
|
{
|
||||||
TrackId = t.Id,
|
TrackId = t.Id,
|
||||||
Title = t.Title,
|
Title = t.Title,
|
||||||
@@ -294,4 +354,24 @@ public class YandexMusicService
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static YandexPlaylistData MapToPlaylistData(YPlaylist playlist) => new()
|
||||||
|
{
|
||||||
|
Title = playlist.Title,
|
||||||
|
Description = playlist.Description,
|
||||||
|
Tracks = playlist.Tracks.Select(t => new YandexTrack
|
||||||
|
{
|
||||||
|
TrackId = t.Track.Id,
|
||||||
|
Title = t.Track.Title,
|
||||||
|
Artists = t.Track.Artists.Select(a => new YandexArtist
|
||||||
|
{
|
||||||
|
Id = a.Id,
|
||||||
|
Name = a.Name,
|
||||||
|
CoverUrl = a.Cover.GetUrl(),
|
||||||
|
Description = a.Description?.Text ?? string.Empty,
|
||||||
|
}).ToList(),
|
||||||
|
DurationMs = (int)(t.Track?.DurationMs ?? 0),
|
||||||
|
CoverUri = t.Track?.CoverUri ?? ""
|
||||||
|
}).ToList()
|
||||||
|
};
|
||||||
}
|
}
|
||||||
BIN
PlaylistShared.Api/ruvector.db
Normal file
BIN
PlaylistShared.Api/ruvector.db
Normal file
Binary file not shown.
@@ -14,7 +14,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>
|
||||||
<MudStack Row Gap="2" AlignItems="AlignItems.Center">
|
<MudStack Row Spacing="2" AlignItems="AlignItems.Center">
|
||||||
<MudTextField @bind-Value="_shareUrl"
|
<MudTextField @bind-Value="_shareUrl"
|
||||||
ReadOnly="true"
|
ReadOnly="true"
|
||||||
Variant="Variant.Outlined"
|
Variant="Variant.Outlined"
|
||||||
|
|||||||
@@ -9,15 +9,14 @@
|
|||||||
|
|
||||||
<MudImage Src="@Track?.CoverUri.FormatCoverUrl(Width, Height)" Height="@Height" Width="@Width" Class="rounded" Style="display: block;" />
|
<MudImage Src="@Track?.CoverUri.FormatCoverUrl(Width, Height)" Height="@Height" Width="@Width" Class="rounded" Style="display: block;" />
|
||||||
|
|
||||||
@if (CanPlay && (_isHovered || IsCurrentTrackPlaying))
|
@if (CanPlay)
|
||||||
{
|
{
|
||||||
<MudItem class="play-overlay"
|
<div class="play-overlay @(_isHovered || IsCurrentTrackPlaying ? "play-overlay--visible" : "")">
|
||||||
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; transition: opacity 0.2s ease; cursor: pointer;">
|
<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" />
|
||||||
</MudItem>
|
</div>
|
||||||
}
|
}
|
||||||
</MudItem>
|
</MudItem>
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
@using PlaylistShared.Pwa.Extensions
|
@using PlaylistShared.Pwa.Extensions
|
||||||
@using PlaylistShared.Shared.Yandex
|
@using PlaylistShared.Shared.Yandex
|
||||||
|
|
||||||
<MudStack Row AlignItems="AlignItems.Center">
|
<MudStack Row AlignItems="AlignItems.Center" Class="@(IsCurrentTrack ? "current-track" : "")">
|
||||||
<!-- Обложка с фиксированной шириной -->
|
<!-- Обложка с фиксированной шириной -->
|
||||||
<MudItem>
|
<MudItem>
|
||||||
<TrackCoverWithPlay TrackId="@Track.TrackId"
|
<TrackCoverWithPlay TrackId="@Track.TrackId"
|
||||||
@@ -17,7 +17,13 @@
|
|||||||
<MudItem>
|
<MudItem>
|
||||||
<MudStack Spacing="0">
|
<MudStack Spacing="0">
|
||||||
<MudText Typo="Typo.body1" Color="Color.Secondary">@Track.Title</MudText>
|
<MudText Typo="Typo.body1" Color="Color.Secondary">@Track.Title</MudText>
|
||||||
<MudText Typo="Typo.body2" >@string.Join(", ", Track.Artists.Select(a => a.Name))</MudText>
|
<MudStack Row AlignItems="AlignItems.Center" Spacing="1">
|
||||||
|
<MudText Typo="Typo.body2">@string.Join(", ", Track.Artists.Select(a => a.Name))</MudText>
|
||||||
|
@if (!string.IsNullOrEmpty(AddedByName))
|
||||||
|
{
|
||||||
|
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined" Color="Color.Secondary" Class="pa-0 px-1" Style="height: 18px; font-size: 10px;">@AddedByName</MudChip>
|
||||||
|
}
|
||||||
|
</MudStack>
|
||||||
</MudStack>
|
</MudStack>
|
||||||
</MudItem>
|
</MudItem>
|
||||||
|
|
||||||
@@ -25,12 +31,26 @@
|
|||||||
|
|
||||||
<!-- Длительность (фиксированная ширина по содержимому) -->
|
<!-- Длительность (фиксированная ширина по содержимому) -->
|
||||||
<MudItem>
|
<MudItem>
|
||||||
<MudText Typo="Typo.body2">@Track.DurationMs.FormatDuration()</MudText>
|
@if (IsCurrentTrack)
|
||||||
|
{
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.GraphicEq" Color="Color.Primary" Size="Size.Small" Class="mr-1 eq-animate" Style="vertical-align: middle;" />
|
||||||
|
}
|
||||||
|
<MudText Typo="Typo.body2" Inline Color="@(IsCurrentTrack ? Color.Primary : Color.Default)">@Track.DurationMs.FormatDuration()</MudText>
|
||||||
</MudItem>
|
</MudItem>
|
||||||
</MudStack>
|
</MudStack>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.current-track {
|
||||||
|
background: color-mix(in srgb, var(--mud-palette-primary) 10%, transparent);
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: inset 2px 0 0 var(--mud-palette-primary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Parameter] public YandexTrack Track { get; set; } = null!;
|
[Parameter] public YandexTrack Track { get; set; } = null!;
|
||||||
[Parameter] public string PlaylistShareToken { get; set; } = string.Empty;
|
[Parameter] public string PlaylistShareToken { get; set; } = string.Empty;
|
||||||
[Parameter] public bool CanPlay { get; set; } = true;
|
[Parameter] public bool CanPlay { get; set; } = true;
|
||||||
|
[Parameter] public string? AddedByName { get; set; }
|
||||||
|
[Parameter] public bool IsCurrentTrack { get; set; }
|
||||||
}
|
}
|
||||||
@@ -6,12 +6,18 @@
|
|||||||
@inject AuthenticationStateProvider AuthProvider
|
@inject AuthenticationStateProvider AuthProvider
|
||||||
@inject ISnackbar Snackbar
|
@inject ISnackbar Snackbar
|
||||||
@inject HttpClient Http
|
@inject HttpClient Http
|
||||||
|
@implements IDisposable
|
||||||
|
@implements IAsyncDisposable
|
||||||
|
|
||||||
<MudStack Spacing="1" Row AlignItems="AlignItems.Center" Wrap="Wrap.NoWrap">
|
<MudStack Spacing="1" Row AlignItems="AlignItems.Center" Wrap="Wrap.NoWrap" Class="player-enter">
|
||||||
<!-- Кнопки управления -->
|
<!-- Кнопки управления -->
|
||||||
<MudItem @onmouseenter="() => { _isPlayHovered = true; }"
|
<MudIconButton Icon="@Icons.Material.Filled.SkipPrevious"
|
||||||
@onmouseleave="() => { _isPlayHovered = false; }"
|
Size="Size.Small"
|
||||||
Class="relative d-inline-block rounded overflow-hidden cursor-pointer"
|
Color="Color.Default"
|
||||||
|
Disabled="@(!AudioPlayerService.HasPrevious)"
|
||||||
|
OnClick="() => AudioPlayerService.PlayPreviousAsync()" />
|
||||||
|
|
||||||
|
<MudItem Class="relative d-inline-block rounded overflow-hidden cursor-pointer"
|
||||||
Style="width: 50px; height: 50px;">
|
Style="width: 50px; height: 50px;">
|
||||||
|
|
||||||
@if (!string.IsNullOrEmpty(AudioPlayerService.CurrentTrack?.CoverUri))
|
@if (!string.IsNullOrEmpty(AudioPlayerService.CurrentTrack?.CoverUri))
|
||||||
@@ -31,6 +37,12 @@
|
|||||||
</MudItem>
|
</MudItem>
|
||||||
</MudItem>
|
</MudItem>
|
||||||
|
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.SkipNext"
|
||||||
|
Size="Size.Small"
|
||||||
|
Color="Color.Default"
|
||||||
|
Disabled="@(!AudioPlayerService.HasNext)"
|
||||||
|
OnClick="() => AudioPlayerService.PlayNextAsync()" />
|
||||||
|
|
||||||
<!-- Название и прогресс -->
|
<!-- Название и прогресс -->
|
||||||
@if (AudioPlayerService.CurrentTrack != null)
|
@if (AudioPlayerService.CurrentTrack != null)
|
||||||
{
|
{
|
||||||
@@ -111,7 +123,6 @@
|
|||||||
private double _volumeBeforeMute;
|
private double _volumeBeforeMute;
|
||||||
private double _bufferSecond;
|
private double _bufferSecond;
|
||||||
|
|
||||||
private bool _isPlayHovered;
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
@@ -165,18 +176,35 @@
|
|||||||
{
|
{
|
||||||
_bufferSecond = second;
|
_bufferSecond = second;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[JSInvokable]
|
||||||
|
public async Task OnKeyboardTogglePlay()
|
||||||
|
{
|
||||||
|
if (AudioPlayerService.IsPlaying)
|
||||||
|
await AudioPlayerService.PauseAsync();
|
||||||
|
else
|
||||||
|
await AudioPlayerService.PlayAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[JSInvokable]
|
||||||
|
public async Task OnKeyboardSeek(double delta)
|
||||||
|
{
|
||||||
|
var newTime = Math.Max(0, AudioPlayerService.CurrentTime + delta);
|
||||||
|
await AudioPlayerService.SeekToAsync(newTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
[JSInvokable]
|
||||||
|
public async Task OnKeyboardVolumeChange(double delta)
|
||||||
|
{
|
||||||
|
var newVol = Math.Clamp(AudioPlayerService.CurrentVolume + delta, 0, 100);
|
||||||
|
await AudioPlayerService.SetVolumeAsync(newVol);
|
||||||
|
}
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Обработка сервиса
|
#region Обработка сервиса
|
||||||
private async Task OnServiceLoadAndPlay(string trackId, string? accessToken, string? sharedPlaylistId)
|
private async Task OnServiceLoadAndPlay(string trackId, string? playToken, string? sharedPlaylistId)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(accessToken))
|
if (string.IsNullOrWhiteSpace(playToken) && string.IsNullOrWhiteSpace(sharedPlaylistId))
|
||||||
{
|
|
||||||
var tokens = await TokenStorage.GetTokensAsync();
|
|
||||||
accessToken = tokens.token;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(accessToken) && string.IsNullOrWhiteSpace(sharedPlaylistId))
|
|
||||||
{
|
{
|
||||||
Snackbar.Add("Токен авторизации не найден. Пожалуйста, войдите заново.", Severity.Error);
|
Snackbar.Add("Токен авторизации не найден. Пожалуйста, войдите заново.", Severity.Error);
|
||||||
return;
|
return;
|
||||||
@@ -184,7 +212,7 @@
|
|||||||
|
|
||||||
var streamUrl = new Uri(Http.BaseAddress!, $"/api/audio/track/{trackId}").ToString();
|
var streamUrl = new Uri(Http.BaseAddress!, $"/api/audio/track/{trackId}").ToString();
|
||||||
await EnsureAudioModuleAsync();
|
await EnsureAudioModuleAsync();
|
||||||
await _audioElement!.InvokeVoidAsync("loadAndPlay", streamUrl, accessToken, sharedPlaylistId);
|
await _audioElement!.InvokeVoidAsync("loadAndPlay", streamUrl, playToken, sharedPlaylistId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task OnServicePlay()
|
private async Task OnServicePlay()
|
||||||
@@ -291,11 +319,26 @@
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
AudioPlayerService.OnLoadAndPlayRequested -= OnServiceLoadAndPlay;
|
||||||
|
AudioPlayerService.OnPlayRequested -= OnServicePlay;
|
||||||
|
AudioPlayerService.OnPauseRequested -= OnServicePause;
|
||||||
|
AudioPlayerService.OnSeekRequested -= OnServiceSeek;
|
||||||
|
AudioPlayerService.OnVolumeChangeRequested -= OnServiceVolumeChange;
|
||||||
|
AudioPlayerService.OnStateChanged -= OnServiceStateChanged;
|
||||||
|
|
||||||
if (_audioElement != null)
|
if (_audioElement != null)
|
||||||
|
{
|
||||||
|
try { await _audioElement.InvokeVoidAsync("destroy"); } catch { }
|
||||||
await _audioElement.DisposeAsync();
|
await _audioElement.DisposeAsync();
|
||||||
|
}
|
||||||
if (_audioModule != null)
|
if (_audioModule != null)
|
||||||
await _audioModule.DisposeAsync();
|
await _audioModule.DisposeAsync();
|
||||||
}
|
}
|
||||||
catch { }
|
catch { }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
DisposeAsync().AsTask().Wait();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
0
PlaylistShared.Pwa/DarkModeToggle())`
Normal file
0
PlaylistShared.Pwa/DarkModeToggle())`
Normal file
@@ -5,11 +5,33 @@ public static class LongExtensions
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Преобразует миллисекунды в формат Минуты:Секунды
|
/// Преобразует миллисекунды в формат Минуты:Секунды
|
||||||
/// </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 seconds = ms / 1000;
|
||||||
var mins = seconds / 60;
|
|
||||||
var secs = seconds % 60;
|
var mm = seconds / 60;
|
||||||
return $"{mins}:{secs:D2}";
|
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
|
public static class CustomIcons
|
||||||
{
|
{
|
||||||
// SVG путь для логотипа Яндекса (буква Я в круге или просто Я)
|
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 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 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'/>";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
@inherits LayoutComponentBase
|
@inherits LayoutComponentBase
|
||||||
@inject PwaUpdateService PwaUpdateService
|
@inject PwaUpdateService PwaUpdateService
|
||||||
@inject IJSRuntime JSRuntime
|
@inject IJSRuntime JSRuntime
|
||||||
|
@inject NavigationManager NavigationManager
|
||||||
@inject ContextualActionBarService ContextualActionBarService
|
@inject ContextualActionBarService ContextualActionBarService
|
||||||
@inject IBrowserViewportService BrowserViewportService
|
@inject IBrowserViewportService BrowserViewportService
|
||||||
@implements IBrowserViewportObserver
|
@implements IBrowserViewportObserver
|
||||||
|
@implements IDisposable
|
||||||
|
|
||||||
<MudThemeProvider Theme="@_theme" IsDarkMode="_isDarkMode" />
|
<MudThemeProvider Theme="@_theme" IsDarkMode="_isDarkMode" />
|
||||||
<MudPopoverProvider />
|
<MudPopoverProvider />
|
||||||
@@ -23,7 +25,7 @@
|
|||||||
{
|
{
|
||||||
<MudSpacer />
|
<MudSpacer />
|
||||||
<LoginDisplay />
|
<LoginDisplay />
|
||||||
<MudIconButton Icon="@(DarkLightModeButtonIcon)" Color="Color.Inherit" OnClick="@DarkModeToggle" Class="ml-2" />
|
<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">
|
<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
|
<MudIcon Icon="@Icons.Custom.Brands.GitHub" Size="Size.Small" Class="mr-1" /> Git
|
||||||
</MudLink>
|
</MudLink>
|
||||||
@@ -68,6 +70,22 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
ContextualActionBarService.OnChanged += OnContextualChangedHandler;
|
ContextualActionBarService.OnChanged += OnContextualChangedHandler;
|
||||||
|
NavigationManager.LocationChanged += OnLocationChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnLocationChanged(object? sender, Microsoft.AspNetCore.Components.Routing.LocationChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_isMobile)
|
||||||
|
{
|
||||||
|
_drawerOpen = false;
|
||||||
|
InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
NavigationManager.LocationChanged -= OnLocationChanged;
|
||||||
|
ContextualActionBarService.OnChanged -= OnContextualChangedHandler;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
@@ -77,6 +95,13 @@
|
|||||||
_dotNetRef = DotNetObjectReference.Create(PwaUpdateService);
|
_dotNetRef = DotNetObjectReference.Create(PwaUpdateService);
|
||||||
await JSRuntime.InvokeVoidAsync("registerSWMessageHandler", _dotNetRef);
|
await JSRuntime.InvokeVoidAsync("registerSWMessageHandler", _dotNetRef);
|
||||||
await BrowserViewportService.SubscribeAsync(this, fireImmediately: true);
|
await BrowserViewportService.SubscribeAsync(this, fireImmediately: true);
|
||||||
|
|
||||||
|
var savedTheme = await JSRuntime.InvokeAsync<string?>("localStorage.getItem", "theme");
|
||||||
|
if (savedTheme != null)
|
||||||
|
{
|
||||||
|
_isDarkMode = savedTheme != "light";
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,9 +117,10 @@
|
|||||||
_drawerOpen = !_drawerOpen;
|
_drawerOpen = !_drawerOpen;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void DarkModeToggle()
|
private async Task DarkModeToggle()
|
||||||
{
|
{
|
||||||
_isDarkMode = !_isDarkMode;
|
_isDarkMode = !_isDarkMode;
|
||||||
|
await JSRuntime.InvokeVoidAsync("localStorage.setItem", "theme", _isDarkMode ? "dark" : "light");
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly PaletteLight _lightPalette = new()
|
private readonly PaletteLight _lightPalette = new()
|
||||||
@@ -154,8 +180,14 @@
|
|||||||
|
|
||||||
Task IBrowserViewportObserver.NotifyBrowserViewportChangeAsync(BrowserViewportEventArgs browserViewportEventArgs)
|
Task IBrowserViewportObserver.NotifyBrowserViewportChangeAsync(BrowserViewportEventArgs browserViewportEventArgs)
|
||||||
{
|
{
|
||||||
|
var wasMobile = _isMobile;
|
||||||
_isMobile = browserViewportEventArgs.Breakpoint <= Breakpoint.Sm;
|
_isMobile = browserViewportEventArgs.Breakpoint <= Breakpoint.Sm;
|
||||||
|
|
||||||
|
if (!wasMobile && _isMobile)
|
||||||
|
_drawerOpen = false;
|
||||||
|
else if (wasMobile && !_isMobile)
|
||||||
|
_drawerOpen = true;
|
||||||
|
|
||||||
return InvokeAsync(StateHasChanged);
|
return InvokeAsync(StateHasChanged);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,8 +47,7 @@
|
|||||||
<MudTd DataLabel="">
|
<MudTd DataLabel="">
|
||||||
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
||||||
Color="Color.Error"
|
Color="Color.Error"
|
||||||
OnClick="() => RemoveFromFavorites(context)"
|
OnClick="() => RemoveFromFavorites(context)" />
|
||||||
Title="Удалить из избранного" />
|
|
||||||
</MudTd>
|
</MudTd>
|
||||||
</RowTemplate>
|
</RowTemplate>
|
||||||
</MudTable>
|
</MudTable>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
@inject NavigationManager Navigation
|
@inject NavigationManager Navigation
|
||||||
@inject AuthenticationStateProvider AuthProvider
|
@inject AuthenticationStateProvider AuthProvider
|
||||||
|
|
||||||
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
|
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-4 mt-sm-8">
|
||||||
<MudCard>
|
<MudCard>
|
||||||
<MudCardHeader>
|
<MudCardHeader>
|
||||||
<CardHeaderContent>
|
<CardHeaderContent>
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
<div style="display: flex; align-items: center; gap: 12px; flex-wrap: wrap;">
|
<div style="display: flex; align-items: center; gap: 12px; flex-wrap: wrap;">
|
||||||
<MudIcon Icon="@Icons.Material.Filled.Share" Size="Size.Medium" />
|
<MudIcon Icon="@Icons.Material.Filled.Share" Size="Size.Medium" />
|
||||||
<div style="flex: 1;">
|
<div style="flex: 1;">
|
||||||
<MudText Typo="Typo.body1" FontWeight="FontWeight.Bold">
|
<MudText Typo="Typo.body1" Style="font-weight: bold;">
|
||||||
Чтобы расшаривать плейлисты, необходимо зарегистрироваться
|
Чтобы расшаривать плейлисты, необходимо зарегистрироваться
|
||||||
</MudText>
|
</MudText>
|
||||||
<MudText Typo="Typo.body2">
|
<MudText Typo="Typo.body2">
|
||||||
@@ -45,19 +45,19 @@
|
|||||||
<!-- Краткие преимущества -->
|
<!-- Краткие преимущества -->
|
||||||
<MudGrid>
|
<MudGrid>
|
||||||
<MudItem xs="12" sm="4">
|
<MudItem xs="12" sm="4">
|
||||||
<MudPaper Class="pa-3 text-center" Elevation="0" Style="background-color: rgba(0,0,0,0.04); border-radius: 8px;">
|
<MudPaper Class="pa-3 text-center feature-card" Elevation="0" Style="background-color: rgba(0,0,0,0.04); border-radius: 8px;">
|
||||||
<MudIcon Icon="@Icons.Material.Filled.Link" Size="Size.Medium" Color="Color.Primary" />
|
<MudIcon Icon="@Icons.Material.Filled.Link" Size="Size.Medium" Color="Color.Primary" />
|
||||||
<MudText Typo="Typo.body2" Class="mt-2">Создавайте ссылки-приглашения</MudText>
|
<MudText Typo="Typo.body2" Class="mt-2">Создавайте ссылки-приглашения</MudText>
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
</MudItem>
|
</MudItem>
|
||||||
<MudItem xs="12" sm="4">
|
<MudItem xs="12" sm="4">
|
||||||
<MudPaper Class="pa-3 text-center" Elevation="0" Style="background-color: rgba(0,0,0,0.04); border-radius: 8px;">
|
<MudPaper Class="pa-3 text-center feature-card" Elevation="0" Style="background-color: rgba(0,0,0,0.04); border-radius: 8px;">
|
||||||
<MudIcon Icon="@Icons.Material.Filled.People" Size="Size.Medium" Color="Color.Primary" />
|
<MudIcon Icon="@Icons.Material.Filled.People" Size="Size.Medium" Color="Color.Primary" />
|
||||||
<MudText Typo="Typo.body2" Class="mt-2">Совместное управление треками</MudText>
|
<MudText Typo="Typo.body2" Class="mt-2">Совместное управление треками</MudText>
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
</MudItem>
|
</MudItem>
|
||||||
<MudItem xs="12" sm="4">
|
<MudItem xs="12" sm="4">
|
||||||
<MudPaper Class="pa-3 text-center" Elevation="0" Style="background-color: rgba(0,0,0,0.04); border-radius: 8px;">
|
<MudPaper Class="pa-3 text-center feature-card" Elevation="0" Style="background-color: rgba(0,0,0,0.04); border-radius: 8px;">
|
||||||
<MudIcon Icon="@Icons.Material.Filled.Security" Size="Size.Medium" Color="Color.Primary" />
|
<MudIcon Icon="@Icons.Material.Filled.Security" Size="Size.Medium" Color="Color.Primary" />
|
||||||
<MudText Typo="Typo.body2" Class="mt-2">Гибкие настройки доступа</MudText>
|
<MudText Typo="Typo.body2" Class="mt-2">Гибкие настройки доступа</MudText>
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
<MudCard>
|
<MudCard>
|
||||||
<MudCardContent Class="text-center">
|
<MudCardContent Class="text-center">
|
||||||
<MudText Typo="Typo.h5" Class="mb-4">Вход в PlaylistShared</MudText>
|
<MudText Typo="Typo.h5" Class="mb-4">Вход в PlaylistShared</MudText>
|
||||||
|
@*
|
||||||
<MudText Typo="Typo.body2" Class="mb-6">
|
<MudText Typo="Typo.body2" Class="mb-6">
|
||||||
Войдите через учётную запись Keycloak или используйте локальный аккаунт.
|
Войдите через учётную запись Keycloak или используйте локальный аккаунт.
|
||||||
</MudText>
|
</MudText>
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
</MudButton>
|
</MudButton>
|
||||||
|
|
||||||
<MudDivider Class="my-4">или</MudDivider>
|
<MudDivider Class="my-4">или</MudDivider>
|
||||||
|
*@
|
||||||
<!-- Локальная форма входа -->
|
<!-- Локальная форма входа -->
|
||||||
<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" @bind-Value:after="LocalLogin" />
|
<MudTextField @bind-Value="_loginModel.Password" Label="Пароль" Variant="Variant.Outlined" FullWidth="true" InputType="InputType.Password" @bind-Value:after="LocalLogin" />
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
</CardHeaderContent>
|
</CardHeaderContent>
|
||||||
<CardHeaderActions>
|
<CardHeaderActions>
|
||||||
<!-- Явно указываем T="bool" для MudSwitch -->
|
<!-- Явно указываем T="bool" для MudSwitch -->
|
||||||
<MudSwitch T="bool" @bind-Checked="_showOnlyShared" Color="Color.Primary" Label="Только расшаренные" />
|
<MudSwitch T="bool" @bind-Value="_showOnlyShared" Color="Color.Primary" Label="Только расшаренные" />
|
||||||
<MudIconButton Icon="@Icons.Material.Filled.Refresh" OnClick="LoadPlaylists" />
|
<MudIconButton Icon="@Icons.Material.Filled.Refresh" OnClick="LoadPlaylists" />
|
||||||
</CardHeaderActions>
|
</CardHeaderActions>
|
||||||
</MudCardHeader>
|
</MudCardHeader>
|
||||||
@@ -71,11 +71,11 @@
|
|||||||
</MudContainer>
|
</MudContainer>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private List<YandexPlaylistShare> _playlists;
|
private List<YandexPlaylistShare> _playlists = new();
|
||||||
private bool _loading = true;
|
private bool _loading = true;
|
||||||
private bool _showOnlyShared = false;
|
private bool _showOnlyShared = false;
|
||||||
|
|
||||||
private List<YandexPlaylistShare> FilteredPlaylists => _showOnlyShared ? _playlists?.Where(p => p.IsShared).ToList() : _playlists;
|
private List<YandexPlaylistShare> FilteredPlaylists => _showOnlyShared ? _playlists.Where(p => p.IsShared).ToList() : _playlists;
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
@@ -89,7 +89,7 @@
|
|||||||
{
|
{
|
||||||
var response = await Http.GetFromJsonAsync<ApiResponse<List<YandexPlaylistShare>>>("/api/playlists");
|
var response = await Http.GetFromJsonAsync<ApiResponse<List<YandexPlaylistShare>>>("/api/playlists");
|
||||||
if (response?.Success == true)
|
if (response?.Success == true)
|
||||||
_playlists = response.Data;
|
_playlists = response.Data ?? new();
|
||||||
else
|
else
|
||||||
Snackbar.Add("Ошибка загрузки плейлистов", Severity.Error);
|
Snackbar.Add("Ошибка загрузки плейлистов", Severity.Error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,7 +48,6 @@
|
|||||||
</MudContainer>
|
</MudContainer>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private string _email = "user@example.com";
|
|
||||||
private string _statusText = "Загрузка...";
|
private string _statusText = "Загрузка...";
|
||||||
private bool _hasToken;
|
private bool _hasToken;
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
var result = await response.Content.ReadFromJsonAsync<ApiResponse<LoginResponse>>();
|
var result = await response.Content.ReadFromJsonAsync<ApiResponse<LoginResponse>>();
|
||||||
if (result?.Success == true)
|
if (result?.Success == true)
|
||||||
{
|
{
|
||||||
await AuthProvider.MarkUserAsAuthenticated(result.Data.Token, result.Data.RefreshToken);
|
await AuthProvider.MarkUserAsAuthenticated(result.Data!.Token, result.Data.RefreshToken);
|
||||||
Navigation.NavigateTo("/");
|
Navigation.NavigateTo("/");
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -60,9 +60,9 @@
|
|||||||
|
|
||||||
public class RegisterModel
|
public class RegisterModel
|
||||||
{
|
{
|
||||||
public string Username { get; set; }
|
public string Username { get; set; } = string.Empty;
|
||||||
public string Email { get; set; }
|
public string Email { get; set; } = string.Empty;
|
||||||
public string Password { get; set; }
|
public string Password { get; set; } = string.Empty;
|
||||||
public string ConfirmPassword { get; set; }
|
public string ConfirmPassword { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,17 @@
|
|||||||
@page "/shared/{token}"
|
@page "/shared/{token}"
|
||||||
<PageTitle>@_playlist?.Title - Playlist Share</PageTitle>
|
<PageTitle>@_playlist?.Title - Playlist Share</PageTitle>
|
||||||
|
|
||||||
|
<HeadContent>
|
||||||
|
<meta property="og:title" content="@(_playlist?.Title ?? "Поделиться плейлистом")" />
|
||||||
|
<meta property="og:description" content="@($"Слушайте плейлист «{_playlist?.Title}» на Playlist Share")" />
|
||||||
|
@if (!string.IsNullOrEmpty(_playlist?.CoverUrl))
|
||||||
|
{
|
||||||
|
<meta property="og:image" content="@_playlist.CoverUrl" />
|
||||||
|
}
|
||||||
|
<meta property="og:url" content="@Navigation.Uri" />
|
||||||
|
<meta property="og:type" content="music.playlist" />
|
||||||
|
</HeadContent>
|
||||||
|
|
||||||
@using PlaylistShared.Pwa.Components.Common
|
@using PlaylistShared.Pwa.Components.Common
|
||||||
@using PlaylistShared.Pwa.Components.Global
|
@using PlaylistShared.Pwa.Components.Global
|
||||||
@using PlaylistShared.Pwa.Components.SharedPlaylist
|
@using PlaylistShared.Pwa.Components.SharedPlaylist
|
||||||
@@ -8,6 +19,7 @@
|
|||||||
@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.Profile
|
||||||
@using PlaylistShared.Shared.SharedPlaylist
|
@using PlaylistShared.Shared.SharedPlaylist
|
||||||
@using PlaylistShared.Shared.Yandex
|
@using PlaylistShared.Shared.Yandex
|
||||||
@inject HttpClient Http
|
@inject HttpClient Http
|
||||||
@@ -17,7 +29,6 @@
|
|||||||
@inject IDialogService DialogService
|
@inject IDialogService DialogService
|
||||||
@inject IAudioPlayerService AudioPlayerService
|
@inject IAudioPlayerService AudioPlayerService
|
||||||
@inject IJSRuntime JS
|
@inject IJSRuntime JS
|
||||||
@inject IDialogService DialogService
|
|
||||||
@implements IDisposable
|
@implements IDisposable
|
||||||
|
|
||||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="py-1 px-1" Style="height: 100%;">
|
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="py-1 px-1" Style="height: 100%;">
|
||||||
@@ -46,6 +57,11 @@
|
|||||||
OnClick="SharePlaylist"
|
OnClick="SharePlaylist"
|
||||||
Size="Size.Medium" />
|
Size="Size.Medium" />
|
||||||
|
|
||||||
|
<MudIconButton Icon="@CustomIcons.YandexMusic"
|
||||||
|
Href="@($"https://music.yandex.ru/playlists/{_playlist?.YandexPlaylistUuid}")"
|
||||||
|
Target="_blank"
|
||||||
|
Size="Size.Medium" />
|
||||||
|
|
||||||
@if (_isCreator && _isAuthenticated)
|
@if (_isCreator && _isAuthenticated)
|
||||||
{
|
{
|
||||||
<MudIconButton Icon="@Icons.Material.Filled.Settings" OnClick="OpenPermissionsDialog" Size="Size.Medium" />
|
<MudIconButton Icon="@Icons.Material.Filled.Settings" OnClick="OpenPermissionsDialog" Size="Size.Medium" />
|
||||||
@@ -127,6 +143,12 @@
|
|||||||
<MudMenuItem Icon="@Icons.Material.Filled.Share"
|
<MudMenuItem Icon="@Icons.Material.Filled.Share"
|
||||||
OnClick="SharePlaylist"
|
OnClick="SharePlaylist"
|
||||||
Label="Поделиться" />
|
Label="Поделиться" />
|
||||||
|
|
||||||
|
<MudMenuItem Icon="@CustomIcons.YandexMusic"
|
||||||
|
Href="@($"https://music.yandex.ru/playlists/{_playlist?.YandexPlaylistUuid}")"
|
||||||
|
Label="Открыть в ЯМ"
|
||||||
|
Target="_blank" />
|
||||||
|
|
||||||
@if (_isCreator && _isAuthenticated)
|
@if (_isCreator && _isAuthenticated)
|
||||||
{
|
{
|
||||||
<MudMenuItem Icon="@Icons.Material.Filled.Settings"
|
<MudMenuItem Icon="@Icons.Material.Filled.Settings"
|
||||||
@@ -186,10 +208,30 @@
|
|||||||
<MudImage Src="@_playlist.CoverUrl.FormatCoverUrl(60, 60)" Height="60" Width="60" Class="rounded shadow-sm" />
|
<MudImage Src="@_playlist.CoverUrl.FormatCoverUrl(60, 60)" Height="60" Width="60" Class="rounded shadow-sm" />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<MudStack Spacing="0" Class="pb-1">
|
||||||
<MudText Typo="Typo.h6" Color="Color.Primary">
|
<MudText Typo="Typo.h6" Color="Color.Primary">
|
||||||
@_playlist?.Title
|
@_playlist?.Title
|
||||||
</MudText>
|
</MudText>
|
||||||
|
|
||||||
|
@if (_tracksLoading)
|
||||||
|
{
|
||||||
|
<MudSkeleton Width="200px" Height="20px" SkeletonType="SkeletonType.Text" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||||
|
@($"Треков: {_playlistTrackCount} • Продолжительность: {_playlistDurationMs.FormatDuration(LongExtensions.FormatDurationType.hhmmss)}")
|
||||||
|
</MudText>
|
||||||
|
}
|
||||||
</MudStack>
|
</MudStack>
|
||||||
|
</MudStack>
|
||||||
|
<MudTextField @bind-Value="_playlistFilterText"
|
||||||
|
Placeholder="Фильтр треков..."
|
||||||
|
Adornment="Adornment.Start"
|
||||||
|
AdornmentIcon="@Icons.Material.Filled.Search"
|
||||||
|
Variant="Variant.Text"
|
||||||
|
FullWidth
|
||||||
|
Class="mb-2" />
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -207,14 +249,17 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<MudTable Items="@_tracks"
|
<MudTable Items="@FilteredPlaylistTracks"
|
||||||
Hover
|
Hover
|
||||||
Elevation="0"
|
Elevation="0"
|
||||||
Breakpoint="Breakpoint.None"
|
Breakpoint="Breakpoint.None"
|
||||||
|
Class="content-fade-in"
|
||||||
Style="min-height: 0;">
|
Style="min-height: 0;">
|
||||||
<RowTemplate>
|
<RowTemplate>
|
||||||
<MudTd Class="py-1 px-0" Style="width: 100%;">
|
<MudTd Class="py-1 px-0" Style="width: 100%;">
|
||||||
<TrackItem Track="@context" PlaylistShareToken="@Token" CanPlay="@_canPlay" />
|
<TrackItem Track="@context" PlaylistShareToken="@Token" CanPlay="@_canPlay"
|
||||||
|
IsCurrentTrack="@(AudioPlayerService.CurrentTrackId == context.TrackId)"
|
||||||
|
AddedByName="@(_trackAdditions.TryGetValue(context.TrackId, out var n) ? n : null)" />
|
||||||
</MudTd>
|
</MudTd>
|
||||||
@if (_canRemove)
|
@if (_canRemove)
|
||||||
{
|
{
|
||||||
@@ -232,7 +277,7 @@
|
|||||||
{
|
{
|
||||||
<MudCard Class="d-flex flex-column" Elevation="0" Style="height: 100%;">
|
<MudCard Class="d-flex flex-column" Elevation="0" Style="height: 100%;">
|
||||||
<MudCardHeader Class="pb-0">
|
<MudCardHeader Class="pb-0">
|
||||||
<MudStack Style="width: 100%;" Spacing="0">
|
<MudStack Style="width: 100%;">
|
||||||
<MudText Typo="Typo.h6" Color="Color.Primary" Class="mb-4">Добавление треков</MudText>
|
<MudText Typo="Typo.h6" Color="Color.Primary" Class="mb-4">Добавление треков</MudText>
|
||||||
<MudTextField @bind-Value="_searchQuery"
|
<MudTextField @bind-Value="_searchQuery"
|
||||||
@bind-Value:after="OnSearchQueryChanged"
|
@bind-Value:after="OnSearchQueryChanged"
|
||||||
@@ -242,19 +287,23 @@
|
|||||||
Label="Название или ссылка на трек"
|
Label="Название или ссылка на трек"
|
||||||
Disabled="@_isSearching"
|
Disabled="@_isSearching"
|
||||||
Adornment="Adornment.End" AdornmentIcon="@Icons.Material.Filled.Search" AdornmentColor="Color.Secondary" />
|
Adornment="Adornment.End" AdornmentIcon="@Icons.Material.Filled.Search" AdornmentColor="Color.Secondary" />
|
||||||
|
<div class="horizontal-scroll-container">
|
||||||
<MudToggleGroup T="TrackSearchType"
|
<MudToggleGroup T="TrackSearchType"
|
||||||
@bind-Value="_searchType"
|
@bind-Value="_searchType"
|
||||||
@bind-Value:after="OnSearchTypeChanged"
|
@bind-Value:after="OnSearchTypeChanged"
|
||||||
Size="Size.Small"
|
Size="Size.Small"
|
||||||
Color="Color.Primary"
|
Color="Color.Primary"
|
||||||
Disabled="@(_isSearching)">
|
Disabled="@(_isSearching)"
|
||||||
<MudToggleItem Value="TrackSearchType.All" Text="Все" />
|
Class="mt-2"
|
||||||
<MudToggleItem Value="TrackSearchType.Track" Text="Треки" />
|
Style="display: inline-flex; flex-wrap: nowrap;">
|
||||||
<MudToggleItem Value="TrackSearchType.Album" Text="Альбомы" />
|
<MudToggleItem Value="TrackSearchType.All" Class="px-2" Text="Все" />
|
||||||
<MudToggleItem Value="TrackSearchType.Playlist" Text="Плейлисты" />
|
<MudToggleItem Value="TrackSearchType.Track" Class="px-2" Text="Треки" />
|
||||||
<MudToggleItem Value="TrackSearchType.Artist" Text="Исполнители" />
|
<MudToggleItem Value="TrackSearchType.Album" Class="px-2" Text="Альбомы" />
|
||||||
|
<MudToggleItem Value="TrackSearchType.Playlist" Class="px-2" Text="Плейлисты" />
|
||||||
|
<MudToggleItem Value="TrackSearchType.Artist" Class="px-2" Text="Исполнители" />
|
||||||
|
<MudToggleItem Value="TrackSearchType.MyPlaylists" Class="px-2" Text="Мои плейлисты" />
|
||||||
</MudToggleGroup>
|
</MudToggleGroup>
|
||||||
|
</div>
|
||||||
</MudStack>
|
</MudStack>
|
||||||
</MudCardHeader>
|
</MudCardHeader>
|
||||||
|
|
||||||
@@ -328,11 +377,46 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@* Секция персональных плейлистов *@
|
||||||
|
@if (_searchResult?.PersonalPlaylists?.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.PersonalPlaylists)
|
||||||
|
{
|
||||||
|
<div style="width: 70px;">
|
||||||
|
<PlaylistCard Item="playlist" OnClick="() => SearchTracksByEntity(playlist.Uuid, playlist.Title, TrackSearchType.Playlist)" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* Секция лайкнутых плейлистов *@
|
||||||
|
@if (_searchResult?.LikedPlaylists?.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.LikedPlaylists)
|
||||||
|
{
|
||||||
|
<div style="width: 70px;">
|
||||||
|
<PlaylistCard Item="playlist" OnClick="() => SearchTracksByEntity(playlist.Uuid, playlist.Title, TrackSearchType.Playlist)" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
@* Секция треков *@
|
@* Секция треков *@
|
||||||
@if (_searchResult?.Tracks != null)
|
@if (_searchResult?.Tracks != null)
|
||||||
{
|
{
|
||||||
<MudText Typo="Typo.h6" Class="mt-4 mb-2 ml-2">Треки</MudText>
|
<MudText Typo="Typo.h6" Class="mt-4 mb-2 ml-2">Треки</MudText>
|
||||||
<MudTable Items="@_searchResult.Tracks"
|
<MudTextField @bind-Value="_searchFilterText"
|
||||||
|
Placeholder="Фильтр треков..."
|
||||||
|
Adornment="Adornment.Start"
|
||||||
|
AdornmentIcon="@Icons.Material.Filled.Search"
|
||||||
|
Variant="Variant.Text"
|
||||||
|
FullWidth
|
||||||
|
Class="mb-2" />
|
||||||
|
<MudTable Items="@FilteredSearchTracks"
|
||||||
Hover
|
Hover
|
||||||
Elevation="0"
|
Elevation="0"
|
||||||
Style="min-height: 0;"
|
Style="min-height: 0;"
|
||||||
@@ -373,6 +457,18 @@
|
|||||||
/// Список добавленных в плейлист треков.
|
/// Список добавленных в плейлист треков.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private List<YandexTrack> _tracks = new();
|
private List<YandexTrack> _tracks = new();
|
||||||
|
/// <summary>
|
||||||
|
/// Словарь trackId → имя пользователя, добавившего трек.
|
||||||
|
/// </summary>
|
||||||
|
private Dictionary<string, string?> _trackAdditions = new();
|
||||||
|
/// <summary>
|
||||||
|
/// Продолжительность плейлиста.
|
||||||
|
/// </summary>
|
||||||
|
long _playlistDurationMs;
|
||||||
|
/// <summary>
|
||||||
|
/// Кол-во треков в ПЛ.
|
||||||
|
/// </summary>
|
||||||
|
int _playlistTrackCount;
|
||||||
|
|
||||||
/// <summary>Свойства плейлиста.</summary>
|
/// <summary>Свойства плейлиста.</summary>
|
||||||
private SharedPlaylistDto? _playlist;
|
private SharedPlaylistDto? _playlist;
|
||||||
@@ -394,6 +490,16 @@
|
|||||||
/// <summary>Состояние: Происходит загрузка треков плейлиста.</summary>
|
/// <summary>Состояние: Происходит загрузка треков плейлиста.</summary>
|
||||||
private bool _tracksLoading = true;
|
private bool _tracksLoading = true;
|
||||||
|
|
||||||
|
/// <summary>Текст фильтра для треков плейлиста</summary>
|
||||||
|
private string _playlistFilterText = "";
|
||||||
|
private List<YandexTrack> FilteredPlaylistTracks =>
|
||||||
|
string.IsNullOrWhiteSpace(_playlistFilterText)
|
||||||
|
? _tracks
|
||||||
|
: _tracks.Where(t => t.Title.Contains(_playlistFilterText, StringComparison.InvariantCultureIgnoreCase) ||
|
||||||
|
t.Artists.Any(a => a.Name.Contains(_playlistFilterText, StringComparison.InvariantCultureIgnoreCase)))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
|
||||||
/********************************
|
/********************************
|
||||||
* Вкладка добавления треков
|
* Вкладка добавления треков
|
||||||
*********************************/
|
*********************************/
|
||||||
@@ -404,12 +510,20 @@
|
|||||||
/// <summary>Состояние: Происходит поиск.</summary>
|
/// <summary>Состояние: Происходит поиск.</summary>
|
||||||
private bool _isSearching = false;
|
private bool _isSearching = false;
|
||||||
/// <summary>Ссылка на поле ввода</summary>
|
/// <summary>Ссылка на поле ввода</summary>
|
||||||
private MudTextField<string> _searchField;
|
private MudTextField<string> _searchField = null!;
|
||||||
/// <summary>Результат поиска.</summary>
|
/// <summary>Результат поиска.</summary>
|
||||||
private YandexSearchResult? _searchResult = null;
|
private YandexSearchResult? _searchResult = null;
|
||||||
|
/// <summary>Текст фильтра для результатов поиска</summary>
|
||||||
|
private string _searchFilterText = "";
|
||||||
|
private List<YandexTrack> FilteredSearchTracks =>
|
||||||
|
string.IsNullOrWhiteSpace(_searchFilterText) || _searchResult?.Tracks == null
|
||||||
|
? _searchResult?.Tracks ?? new List<YandexTrack>()
|
||||||
|
: _searchResult.Tracks.Where(t => t.Title.Contains(_searchFilterText, StringComparison.InvariantCultureIgnoreCase) ||
|
||||||
|
t.Artists.Any(a => a.Name.Contains(_searchFilterText, StringComparison.InvariantCultureIgnoreCase)))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
/********************************
|
/********************************
|
||||||
* Вкладка добавления треков
|
* Контекстные кнопки
|
||||||
*********************************/
|
*********************************/
|
||||||
/// <summary>Признак, что альбом в фаворитах.</summary>
|
/// <summary>Признак, что альбом в фаворитах.</summary>
|
||||||
private bool _isFavorite;
|
private bool _isFavorite;
|
||||||
@@ -522,7 +636,18 @@
|
|||||||
if (response?.Success == true && response.Data != null)
|
if (response?.Success == true && response.Data != null)
|
||||||
{
|
{
|
||||||
_tracks = response.Data.Tracks;
|
_tracks = response.Data.Tracks;
|
||||||
await GenerateUniqTrackIds();
|
_existingTrackIds = _tracks.Select(t => t.TrackId).ToHashSet();
|
||||||
|
_playlistDurationMs = _tracks.Sum(t => t.DurationMs);
|
||||||
|
_playlistTrackCount = _tracks.Count();
|
||||||
|
AudioPlayerService.SetQueue(_tracks, shareToken: Token);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var additionsResp = await Http.GetFromJsonAsync<ApiResponse<Dictionary<string, string?>>>($"/api/sharedplaylist/{Token}/additions");
|
||||||
|
if (additionsResp?.Success == true && additionsResp.Data != null)
|
||||||
|
_trackAdditions = additionsResp.Data;
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -541,11 +666,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task GenerateUniqTrackIds()
|
|
||||||
{
|
|
||||||
_existingTrackIds = _tracks.Select(t => t.TrackId).ToHashSet();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Удаление трека из списка плейлиста</summary>
|
/// <summary>Удаление трека из списка плейлиста</summary>
|
||||||
private async Task OnRemoveTrack(YandexTrack track)
|
private async Task OnRemoveTrack(YandexTrack track)
|
||||||
{
|
{
|
||||||
@@ -560,7 +680,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
#region Добавление/удаление трека
|
#region Добавление/удаление трека
|
||||||
/// <summary>Добавление трека.</summary>
|
/// <summary>Добавление трека.</summary>
|
||||||
private async Task AddTrack(YandexTrack track)
|
private async Task AddTrack(YandexTrack track)
|
||||||
@@ -574,8 +693,10 @@
|
|||||||
if (response.IsSuccessStatusCode)
|
if (response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
Snackbar.Add($"Трек \"{track.Title}\" добавлен", Severity.Success, c => c.SnackbarVariant = Variant.Outlined);
|
Snackbar.Add($"Трек \"{track.Title}\" добавлен", Severity.Success, c => c.SnackbarVariant = Variant.Outlined);
|
||||||
_tracks.Add(track);
|
_tracks.Insert(0, track);
|
||||||
await GenerateUniqTrackIds();
|
_existingTrackIds.Add(track.TrackId);
|
||||||
|
_playlistDurationMs += track.DurationMs;
|
||||||
|
_playlistTrackCount += 1;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -604,7 +725,9 @@
|
|||||||
{
|
{
|
||||||
Snackbar.Add("Трек удалён", Severity.Success);
|
Snackbar.Add("Трек удалён", Severity.Success);
|
||||||
_tracks.Remove(track);
|
_tracks.Remove(track);
|
||||||
await GenerateUniqTrackIds();
|
_existingTrackIds.Remove(track.TrackId);
|
||||||
|
_playlistDurationMs -= track.DurationMs;
|
||||||
|
_playlistTrackCount -= 1;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -640,13 +763,50 @@
|
|||||||
private async Task SearchTracks(bool byId = false, string? forcedQuery = null)
|
private async Task SearchTracks(bool byId = false, string? forcedQuery = null)
|
||||||
{
|
{
|
||||||
var query = forcedQuery ?? _searchQuery;
|
var query = forcedQuery ?? _searchQuery;
|
||||||
if (string.IsNullOrWhiteSpace(query))
|
var type = _searchType;
|
||||||
|
|
||||||
|
|
||||||
|
//Если поиск в моих плейлистах
|
||||||
|
if (type == TrackSearchType.MyPlaylists)
|
||||||
|
{
|
||||||
|
var showMessage = true;
|
||||||
|
|
||||||
|
if (_isAuthenticated)
|
||||||
|
{
|
||||||
|
var response = await Http.GetFromJsonAsync<ApiResponse<YandexTokenStatus>>("/api/yandexaccount/status");
|
||||||
|
if (response?.Success == true)
|
||||||
|
{
|
||||||
|
var hasToken = response?.Data?.HasToken ?? false;
|
||||||
|
|
||||||
|
if (hasToken) showMessage = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showMessage)
|
||||||
|
{
|
||||||
|
var response = await DialogService.ShowMessageBoxAsync(new()
|
||||||
|
{
|
||||||
|
Title = "Необходимо авторизация",
|
||||||
|
Message = "Для использования \"Мои плейлисты\" необходима авторизация в яндекс музыке.",
|
||||||
|
YesText = "Авторизоваться",
|
||||||
|
CancelText = "Отмена",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response == true)
|
||||||
|
{
|
||||||
|
Navigation.NavigateTo("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//Если обычный поиск, нужен текст
|
||||||
|
else if (string.IsNullOrWhiteSpace(query))
|
||||||
{
|
{
|
||||||
_searchResult = null;
|
_searchResult = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var type = _searchType;
|
|
||||||
|
|
||||||
// Распознавание ссылки Яндекс.Музыки
|
// Распознавание ссылки Яндекс.Музыки
|
||||||
if (!byId && Uri.TryCreate(query, UriKind.Absolute, out var uri) && uri.Host == "music.yandex.ru")
|
if (!byId && Uri.TryCreate(query, UriKind.Absolute, out var uri) && uri.Host == "music.yandex.ru")
|
||||||
@@ -671,7 +831,7 @@
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var url = $"/api/yandexsearch/search?query={Uri.EscapeDataString(query)}&searchType={Uri.EscapeDataString(type.ToString())}&limit=20";
|
var url = $"/api/yandexsearch/search?query={Uri.EscapeDataString(query)}&searchType={Uri.EscapeDataString(type.ToString())}";
|
||||||
if (byId)
|
if (byId)
|
||||||
url += "&byId=true";
|
url += "&byId=true";
|
||||||
if (!string.IsNullOrEmpty(Token))
|
if (!string.IsNullOrEmpty(Token))
|
||||||
@@ -831,7 +991,7 @@
|
|||||||
}
|
}
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Поделитьсы ссылкой
|
#region Поделиться ссылкой
|
||||||
/// <summary> Поделиться ссылкой </summary>
|
/// <summary> Поделиться ссылкой </summary>
|
||||||
private async Task SharePlaylist()
|
private async Task SharePlaylist()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using MudBlazor;
|
using MudBlazor;
|
||||||
using PlaylistShared.Shared;
|
using PlaylistShared.Shared;
|
||||||
using PlaylistShared.Shared.Yandex;
|
using PlaylistShared.Shared.Yandex;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
|
|
||||||
namespace PlaylistShared.Pwa.Services;
|
namespace PlaylistShared.Pwa.Services;
|
||||||
@@ -22,9 +23,16 @@ public class AudioPlayerService : IAudioPlayerService
|
|||||||
private string _currentTimeString = "0:00";
|
private string _currentTimeString = "0:00";
|
||||||
private string _totalTimeString = "0:00";
|
private string _totalTimeString = "0:00";
|
||||||
|
|
||||||
|
private List<YandexTrack> _queue = new();
|
||||||
|
private int _queueIndex = -1;
|
||||||
|
private string? _queueShareToken;
|
||||||
|
|
||||||
public string? CurrentTrackId => _currentTrackId;
|
public string? CurrentTrackId => _currentTrackId;
|
||||||
public YandexTrack? CurrentTrack => _currentTrack;
|
public YandexTrack? CurrentTrack => _currentTrack;
|
||||||
public bool IsPlaying => _isPlaying;
|
public bool IsPlaying => _isPlaying;
|
||||||
|
public IReadOnlyList<YandexTrack> CurrentQueue => _queue;
|
||||||
|
public bool HasNext => _queueIndex >= 0 && _queueIndex < _queue.Count - 1;
|
||||||
|
public bool HasPrevious => _queueIndex > 0;
|
||||||
public double CurrentVolume
|
public double CurrentVolume
|
||||||
{
|
{
|
||||||
get => _currentVolume;
|
get => _currentVolume;
|
||||||
@@ -57,14 +65,10 @@ public class AudioPlayerService : IAudioPlayerService
|
|||||||
private async Task LoadVolume()
|
private async Task LoadVolume()
|
||||||
{
|
{
|
||||||
var savedVolume = await _playerStorage.GetVolumeAsync();
|
var savedVolume = await _playerStorage.GetVolumeAsync();
|
||||||
|
|
||||||
if (savedVolume != null)
|
if (savedVolume != null)
|
||||||
{
|
|
||||||
_currentVolume = savedVolume.Value;
|
_currentVolume = savedVolume.Value;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Внешние команды (вызываются из компонентов)
|
|
||||||
public async Task LoadAndPlayAsync(string trackId, string? accessToken = null, string? playlistShareToken = null, YandexTrack? track = null)
|
public async Task LoadAndPlayAsync(string trackId, string? accessToken = null, string? playlistShareToken = null, YandexTrack? track = null)
|
||||||
{
|
{
|
||||||
if (_currentTrackId == trackId)
|
if (_currentTrackId == trackId)
|
||||||
@@ -75,30 +79,33 @@ public class AudioPlayerService : IAudioPlayerService
|
|||||||
|
|
||||||
_currentTrackId = trackId;
|
_currentTrackId = trackId;
|
||||||
|
|
||||||
// Если accessToken не передан, пытаемся получить его из хранилища
|
var idx = _queue.FindIndex(t => t.TrackId == trackId);
|
||||||
|
if (idx >= 0) _queueIndex = idx;
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(accessToken))
|
if (string.IsNullOrWhiteSpace(accessToken))
|
||||||
{
|
{
|
||||||
var tokens = await _tokenStorage.GetTokensAsync();
|
var tokens = await _tokenStorage.GetTokensAsync();
|
||||||
accessToken = tokens.token;
|
accessToken = tokens.token;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Проверяем, есть ли чем авторизоваться
|
string? playToken = null;
|
||||||
if (string.IsNullOrWhiteSpace(accessToken) && string.IsNullOrWhiteSpace(playlistShareToken))
|
if (!string.IsNullOrWhiteSpace(accessToken))
|
||||||
|
playToken = await FetchPlayTokenAsync(accessToken);
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(playToken) && string.IsNullOrWhiteSpace(playlistShareToken))
|
||||||
{
|
{
|
||||||
_snackbar.Add("Не удалось воспроизвести трек: отсутствует токен авторизации или идентификатор расшаренного плейлиста.", Severity.Error);
|
_snackbar.Add("Не удалось воспроизвести трек: отсутствует токен авторизации или идентификатор расшаренного плейлиста.", Severity.Error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Если title и coverUrl не переданы, нужно запросить через API
|
|
||||||
if (track is null)
|
if (track is null)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
track = await GetTrackInfo(trackId, accessToken, playlistShareToken);
|
track = await GetTrackInfo(trackId, playToken, playlistShareToken);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
// Логируем ошибку, но продолжаем без обложки/названия
|
|
||||||
Console.WriteLine($"Failed to fetch track info: {ex.Message}");
|
Console.WriteLine($"Failed to fetch track info: {ex.Message}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,7 +113,7 @@ public class AudioPlayerService : IAudioPlayerService
|
|||||||
_currentTrack = track;
|
_currentTrack = track;
|
||||||
_isPlaying = true;
|
_isPlaying = true;
|
||||||
OnStateChanged?.Invoke();
|
OnStateChanged?.Invoke();
|
||||||
OnLoadAndPlayRequested?.Invoke(trackId, accessToken, playlistShareToken);
|
OnLoadAndPlayRequested?.Invoke(trackId, playToken, playlistShareToken);
|
||||||
OnStartedTrack?.Invoke();
|
OnStartedTrack?.Invoke();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,14 +145,36 @@ public class AudioPlayerService : IAudioPlayerService
|
|||||||
await _playerStorage.SetVolumeAsync(volume);
|
await _playerStorage.SetVolumeAsync(volume);
|
||||||
}
|
}
|
||||||
|
|
||||||
// События для связи с реальным AudioPlayer компонентом
|
public void SetQueue(IEnumerable<YandexTrack> tracks, int startIndex = 0, string? shareToken = null)
|
||||||
|
{
|
||||||
|
_queue = tracks.ToList();
|
||||||
|
_queueIndex = _queue.Count > 0 ? Math.Clamp(startIndex, 0, _queue.Count - 1) : -1;
|
||||||
|
_queueShareToken = shareToken;
|
||||||
|
OnStateChanged?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task PlayNextAsync()
|
||||||
|
{
|
||||||
|
if (!HasNext) return;
|
||||||
|
_queueIndex++;
|
||||||
|
var track = _queue[_queueIndex];
|
||||||
|
await LoadAndPlayAsync(track.TrackId, playlistShareToken: _queueShareToken, track: track);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task PlayPreviousAsync()
|
||||||
|
{
|
||||||
|
if (!HasPrevious) return;
|
||||||
|
_queueIndex--;
|
||||||
|
var track = _queue[_queueIndex];
|
||||||
|
await LoadAndPlayAsync(track.TrackId, playlistShareToken: _queueShareToken, track: track);
|
||||||
|
}
|
||||||
|
|
||||||
public event Func<string, string?, string?, Task>? OnLoadAndPlayRequested;
|
public event Func<string, string?, string?, Task>? OnLoadAndPlayRequested;
|
||||||
public event Func<Task>? OnPlayRequested;
|
public event Func<Task>? OnPlayRequested;
|
||||||
public event Func<Task>? OnPauseRequested;
|
public event Func<Task>? OnPauseRequested;
|
||||||
public event Func<double, Task>? OnSeekRequested;
|
public event Func<double, Task>? OnSeekRequested;
|
||||||
public event Func<double, Task>? OnVolumeChangeRequested;
|
public event Func<double, Task>? OnVolumeChangeRequested;
|
||||||
|
|
||||||
// Внутренние методы для обновления состояния из AudioPlayer
|
|
||||||
public void SetPlayingState(bool isPlaying)
|
public void SetPlayingState(bool isPlaying)
|
||||||
{
|
{
|
||||||
_isPlaying = isPlaying;
|
_isPlaying = isPlaying;
|
||||||
@@ -172,37 +201,40 @@ public class AudioPlayerService : IAudioPlayerService
|
|||||||
public void NotifyTrackEnded()
|
public void NotifyTrackEnded()
|
||||||
{
|
{
|
||||||
_isPlaying = false;
|
_isPlaying = false;
|
||||||
_currentTrackId = null;
|
|
||||||
_currentProgress = 0;
|
_currentProgress = 0;
|
||||||
_currentTime = 0;
|
_currentTime = 0;
|
||||||
_currentTimeString = "0:00";
|
_currentTimeString = "0:00";
|
||||||
_totalTime = 0;
|
_totalTime = 0;
|
||||||
_currentTimeString = "0:00";
|
_totalTimeString = "0:00";
|
||||||
OnStateChanged?.Invoke();
|
OnStateChanged?.Invoke();
|
||||||
OnEndedTrack?.Invoke();
|
OnEndedTrack?.Invoke();
|
||||||
|
|
||||||
|
if (HasNext)
|
||||||
|
_ = PlayNextAsync();
|
||||||
|
else
|
||||||
|
_currentTrackId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
private async Task<string?> FetchPlayTokenAsync(string jwt)
|
||||||
/// Вспомогательный метод для получения информации о треке через API
|
{
|
||||||
/// </summary>
|
using var request = new HttpRequestMessage(HttpMethod.Get, "/api/audio/play-token");
|
||||||
/// <param name="trackId"></param>
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
|
||||||
/// <param name="accessToken"></param>
|
using var response = await _http.SendAsync(request);
|
||||||
/// <param name="sharedPlaylistId"></param>
|
if (!response.IsSuccessStatusCode) return null;
|
||||||
/// <returns></returns>
|
var result = await response.Content.ReadFromJsonAsync<ApiResponse<string>>();
|
||||||
private async Task<YandexTrack?> GetTrackInfo(string trackId, string? accessToken, string? sharedPlaylistId)
|
return result?.Data;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<YandexTrack?> GetTrackInfo(string trackId, string? playToken, string? sharedPlaylistId)
|
||||||
{
|
{
|
||||||
var url = $"/api/audio/track-info/{trackId}";
|
var url = $"/api/audio/track-info/{trackId}";
|
||||||
if (!string.IsNullOrEmpty(accessToken))
|
if (!string.IsNullOrEmpty(playToken))
|
||||||
url += $"?access_token={accessToken}";
|
url += $"?play_token={playToken}";
|
||||||
else if (!string.IsNullOrEmpty(sharedPlaylistId))
|
else if (!string.IsNullOrEmpty(sharedPlaylistId))
|
||||||
url += $"?shared_id={sharedPlaylistId}";
|
url += $"?shared_id={sharedPlaylistId}";
|
||||||
|
|
||||||
var response = await _http.GetFromJsonAsync<ApiResponse<YandexTrack>>(url);
|
var response = await _http.GetFromJsonAsync<ApiResponse<YandexTrack>>(url);
|
||||||
if (response?.Success == true)
|
return response?.Success == true ? response.Data : null;
|
||||||
{
|
|
||||||
return response.Data;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private string FormatDuration(double seconds)
|
private string FormatDuration(double seconds)
|
||||||
|
|||||||
@@ -12,6 +12,15 @@ public interface IAudioPlayerService
|
|||||||
/// <summary>ID текущего воспроизводимого трека (null, если ничего не играет).</summary>
|
/// <summary>ID текущего воспроизводимого трека (null, если ничего не играет).</summary>
|
||||||
string? CurrentTrackId { get; }
|
string? CurrentTrackId { get; }
|
||||||
|
|
||||||
|
/// <summary>Очередь треков.</summary>
|
||||||
|
IReadOnlyList<YandexTrack> CurrentQueue { get; }
|
||||||
|
|
||||||
|
/// <summary>Есть ли следующий трек в очереди.</summary>
|
||||||
|
bool HasNext { get; }
|
||||||
|
|
||||||
|
/// <summary>Есть ли предыдущий трек в очереди.</summary>
|
||||||
|
bool HasPrevious { get; }
|
||||||
|
|
||||||
/// <summary>Играет ли в данный момент (true) или приостановлен (false).</summary>
|
/// <summary>Играет ли в данный момент (true) или приостановлен (false).</summary>
|
||||||
bool IsPlaying { get; }
|
bool IsPlaying { get; }
|
||||||
|
|
||||||
@@ -57,6 +66,15 @@ public interface IAudioPlayerService
|
|||||||
|
|
||||||
/// <summary>Установить громкость (0–100).</summary>
|
/// <summary>Установить громкость (0–100).</summary>
|
||||||
Task SetVolumeAsync(double volume);
|
Task SetVolumeAsync(double volume);
|
||||||
|
|
||||||
|
/// <summary>Установить очередь треков и начать воспроизведение с указанного индекса.</summary>
|
||||||
|
void SetQueue(IEnumerable<YandexTrack> tracks, int startIndex = 0, string? shareToken = null);
|
||||||
|
|
||||||
|
/// <summary>Перейти к следующему треку в очереди.</summary>
|
||||||
|
Task PlayNextAsync();
|
||||||
|
|
||||||
|
/// <summary>Перейти к предыдущему треку в очереди.</summary>
|
||||||
|
Task PlayPreviousAsync();
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region События для подписки на изменения состояния
|
#region События для подписки на изменения состояния
|
||||||
|
|||||||
@@ -18,6 +18,29 @@ http {
|
|||||||
gzip_min_length 1024;
|
gzip_min_length 1024;
|
||||||
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/wasm application/json;
|
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/wasm application/json;
|
||||||
|
|
||||||
|
# Бэкенд API (для OG-тегов)
|
||||||
|
upstream api_backend {
|
||||||
|
server playlistshared_api:80;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Определяем, является ли запрос ботом-краулером
|
||||||
|
map $http_user_agent $is_crawler {
|
||||||
|
default 0;
|
||||||
|
~*Slackbot 1;
|
||||||
|
~*facebookexternalhit 1;
|
||||||
|
~*Twitterbot 1;
|
||||||
|
~*TelegramBot 1;
|
||||||
|
~*WhatsApp 1;
|
||||||
|
~*LinkedInBot 1;
|
||||||
|
~*vkShare 1;
|
||||||
|
~*Pinterest 1;
|
||||||
|
~*Googlebot 1;
|
||||||
|
~*YandexBot 1;
|
||||||
|
~*Discordbot 1;
|
||||||
|
~*Applebot 1;
|
||||||
|
~*DuckDuckBot 1;
|
||||||
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name localhost;
|
server_name localhost;
|
||||||
@@ -54,6 +77,18 @@ http {
|
|||||||
try_files $uri =404;
|
try_files $uri =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# OG-теги: краулеры получают HTML с meta-тегами от API
|
||||||
|
location ~ ^/shared/(.+)$ {
|
||||||
|
if ($is_crawler) {
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_pass http://api_backend;
|
||||||
|
}
|
||||||
|
try_files $uri $uri/ /index.html?$args;
|
||||||
|
}
|
||||||
|
|
||||||
# Основной SPA fallback: все неизвестные пути отдаём через index.html
|
# Основной SPA fallback: все неизвестные пути отдаём через index.html
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html?$args;
|
try_files $uri $uri/ /index.html?$args;
|
||||||
|
|||||||
@@ -113,15 +113,108 @@ code {
|
|||||||
.horizontal-scroll {
|
.horizontal-scroll {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
scroll-snap-type: x mandatory;
|
scroll-snap-type: x mandatory;
|
||||||
overflow-y: hidden; /* отключаем вертикальный скролл */
|
overflow-y: hidden;
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
}
|
}
|
||||||
|
|
||||||
.horizontal-scroll:active {
|
.horizontal-scroll:active {
|
||||||
cursor: grabbing;
|
cursor: grabbing;
|
||||||
}
|
}
|
||||||
/* Для WebKit (Chrome, Edge, Safari) можно включить горизонтальный скролл мышью */
|
|
||||||
.horizontal-scroll {
|
.horizontal-scroll {
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== Animations ===== */
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUpFade {
|
||||||
|
from { opacity: 0; transform: translateY(10px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes equalizerPulse {
|
||||||
|
0%, 100% { transform: scaleY(0.5) translateY(3px); }
|
||||||
|
50% { transform: scaleY(1) translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes playerSlideUp {
|
||||||
|
from { opacity: 0; transform: translateY(20px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fade-in for content that loads */
|
||||||
|
.content-fade-in {
|
||||||
|
animation: fadeIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Track items slide up when list loads */
|
||||||
|
.tracks-slide-in {
|
||||||
|
animation: slideUpFade 0.25s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Equalizer icon bounce */
|
||||||
|
.eq-animate {
|
||||||
|
display: inline-block;
|
||||||
|
transform-origin: bottom center;
|
||||||
|
animation: equalizerPulse 0.55s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Feature cards on home page */
|
||||||
|
.feature-card {
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease !important;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card:hover {
|
||||||
|
transform: translateY(-3px) !important;
|
||||||
|
box-shadow: 0 6px 20px rgba(126, 111, 255, 0.18) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Audio player entrance */
|
||||||
|
.player-enter {
|
||||||
|
animation: playerSlideUp 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Play overlay — opacity-based show/hide */
|
||||||
|
.play-overlay {
|
||||||
|
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;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-overlay.play-overlay--visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Touch devices: play overlay always visible */
|
||||||
|
@media (hover: none) {
|
||||||
|
.play-overlay {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Current track — smooth highlight transition */
|
||||||
|
.current-track {
|
||||||
|
transition: background 0.35s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile padding tightening */
|
||||||
|
@media (max-width: 599px) {
|
||||||
|
.mud-container {
|
||||||
|
padding-left: 6px !important;
|
||||||
|
padding-right: 6px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
const loadAndPlay = (src, token, sharedPlaylistId) => {
|
const loadAndPlay = (src, token, sharedPlaylistId) => {
|
||||||
const url = new URL(src, window.location.href);
|
const url = new URL(src, window.location.href);
|
||||||
if (token) url.searchParams.set('access_token', token);
|
if (token) url.searchParams.set('play_token', token);
|
||||||
if (sharedPlaylistId) url.searchParams.set('shared_id', sharedPlaylistId);
|
if (sharedPlaylistId) url.searchParams.set('shared_id', sharedPlaylistId);
|
||||||
audio.src = url.toString();
|
audio.src = url.toString();
|
||||||
audio.load();
|
audio.load();
|
||||||
@@ -58,6 +58,36 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Возвращаем все методы, которые будут вызываться из C#
|
const handleKeyDown = (e) => {
|
||||||
return { loadAndPlay, play, pause, stop, setVolume, setCurrentTime };
|
const tag = e.target.tagName.toLowerCase();
|
||||||
|
if (tag === 'input' || tag === 'textarea' || e.target.isContentEditable) return;
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case ' ':
|
||||||
|
e.preventDefault();
|
||||||
|
dotNetHelper.invokeMethodAsync('OnKeyboardTogglePlay');
|
||||||
|
break;
|
||||||
|
case 'ArrowLeft':
|
||||||
|
e.preventDefault();
|
||||||
|
dotNetHelper.invokeMethodAsync('OnKeyboardSeek', -10);
|
||||||
|
break;
|
||||||
|
case 'ArrowRight':
|
||||||
|
e.preventDefault();
|
||||||
|
dotNetHelper.invokeMethodAsync('OnKeyboardSeek', 10);
|
||||||
|
break;
|
||||||
|
case 'ArrowUp':
|
||||||
|
e.preventDefault();
|
||||||
|
dotNetHelper.invokeMethodAsync('OnKeyboardVolumeChange', 5);
|
||||||
|
break;
|
||||||
|
case 'ArrowDown':
|
||||||
|
e.preventDefault();
|
||||||
|
dotNetHelper.invokeMethodAsync('OnKeyboardVolumeChange', -5);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
|
||||||
|
const destroy = () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
|
||||||
|
return { loadAndPlay, play, pause, stop, setVolume, setCurrentTime, destroy };
|
||||||
}
|
}
|
||||||
@@ -13,4 +13,5 @@ public enum TrackSearchType
|
|||||||
Album,
|
Album,
|
||||||
Playlist,
|
Playlist,
|
||||||
Track,
|
Track,
|
||||||
|
MyPlaylists,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,5 +2,5 @@
|
|||||||
|
|
||||||
public class SetYandexTokenRequest
|
public class SetYandexTokenRequest
|
||||||
{
|
{
|
||||||
public string Token { get; set; }
|
public string Token { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,5 +2,5 @@
|
|||||||
|
|
||||||
public class AddTrackByLinkRequest
|
public class AddTrackByLinkRequest
|
||||||
{
|
{
|
||||||
public string Link { get; set; }
|
public string Link { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
@@ -36,7 +36,7 @@ public class SharePlaylistDto
|
|||||||
|
|
||||||
/// <summary>Токен для расшаривания плейлиста.</summary>
|
/// <summary>Токен для расшаривания плейлиста.</summary>
|
||||||
[JsonPropertyName("shareToken")]
|
[JsonPropertyName("shareToken")]
|
||||||
public string ShareToken { get; set; }
|
public string ShareToken { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>Права на просмотр.</summary>
|
/// <summary>Права на просмотр.</summary>
|
||||||
[JsonPropertyName("viewPermission")]
|
[JsonPropertyName("viewPermission")]
|
||||||
|
|||||||
@@ -2,6 +2,6 @@
|
|||||||
|
|
||||||
public class SharePlaylistRequest
|
public class SharePlaylistRequest
|
||||||
{
|
{
|
||||||
public string Kind { get; set; }
|
public string Kind { get; set; } = string.Empty;
|
||||||
public string OwnerUid { get; set; }
|
public string OwnerUid { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
@@ -17,6 +17,18 @@ public class YandexSearchResult
|
|||||||
[JsonPropertyName("playlists")]
|
[JsonPropertyName("playlists")]
|
||||||
public List<YandexPlaylist>? Playlists { get; set; } = null;
|
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>
|
||||||
/// Найденные исполнители.
|
/// Найденные исполнители.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
Reference in New Issue
Block a user