Добавлен playlist shared
This commit is contained in:
23
PlaylistShared/Components/App.razor
Normal file
23
PlaylistShared/Components/App.razor
Normal file
@@ -0,0 +1,23 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<base href="/" />
|
||||
<ResourcePreloader />
|
||||
<link rel="stylesheet" href="@Assets["lib/bootstrap/dist/css/bootstrap.min.css"]" />
|
||||
<link rel="stylesheet" href="@Assets["app.css"]" />
|
||||
<link rel="stylesheet" href="@Assets["PlaylistShared.styles.css"]" />
|
||||
<ImportMap />
|
||||
<link rel="icon" type="image/png" href="favicon.png" />
|
||||
<HeadOutlet />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<Routes />
|
||||
<ReconnectModal />
|
||||
<script src="@Assets["_framework/blazor.web.js"]"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
23
PlaylistShared/Components/Layout/MainLayout.razor
Normal file
23
PlaylistShared/Components/Layout/MainLayout.razor
Normal file
@@ -0,0 +1,23 @@
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
<div class="page">
|
||||
<div class="sidebar">
|
||||
<NavMenu />
|
||||
</div>
|
||||
|
||||
<main>
|
||||
<div class="top-row px-4">
|
||||
<a href="https://learn.microsoft.com/aspnet/core/" target="_blank">About</a>
|
||||
</div>
|
||||
|
||||
<article class="content px-4">
|
||||
@Body
|
||||
</article>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div id="blazor-error-ui" data-nosnippet>
|
||||
An unhandled error has occurred.
|
||||
<a href="." class="reload">Reload</a>
|
||||
<span class="dismiss">🗙</span>
|
||||
</div>
|
||||
98
PlaylistShared/Components/Layout/MainLayout.razor.css
Normal file
98
PlaylistShared/Components/Layout/MainLayout.razor.css
Normal file
@@ -0,0 +1,98 @@
|
||||
.page {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%);
|
||||
}
|
||||
|
||||
.top-row {
|
||||
background-color: #f7f7f7;
|
||||
border-bottom: 1px solid #d6d5d5;
|
||||
justify-content: flex-end;
|
||||
height: 3.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.top-row ::deep a, .top-row ::deep .btn-link {
|
||||
white-space: nowrap;
|
||||
margin-left: 1.5rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.top-row ::deep a:hover, .top-row ::deep .btn-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.top-row ::deep a:first-child {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@media (max-width: 640.98px) {
|
||||
.top-row {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.top-row ::deep a, .top-row ::deep .btn-link {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 641px) {
|
||||
.page {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 250px;
|
||||
height: 100vh;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.top-row {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.top-row.auth ::deep a:first-child {
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.top-row, article {
|
||||
padding-left: 2rem !important;
|
||||
padding-right: 1.5rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
#blazor-error-ui {
|
||||
color-scheme: light only;
|
||||
background: lightyellow;
|
||||
bottom: 0;
|
||||
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
|
||||
box-sizing: border-box;
|
||||
display: none;
|
||||
left: 0;
|
||||
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
#blazor-error-ui .dismiss {
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
top: 0.5rem;
|
||||
}
|
||||
51
PlaylistShared/Components/Layout/NavMenu.razor
Normal file
51
PlaylistShared/Components/Layout/NavMenu.razor
Normal file
@@ -0,0 +1,51 @@
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
<div class="top-row ps-3 navbar navbar-dark">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="">PlaylistShared</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input type="checkbox" title="Navigation menu" class="navbar-toggler" />
|
||||
|
||||
<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()">
|
||||
<nav class="nav flex-column">
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
|
||||
<span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Главная
|
||||
</NavLink>
|
||||
</div>
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="myplaylists">
|
||||
<span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> Мои плейлисты
|
||||
</NavLink>
|
||||
</div>
|
||||
@if (user?.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="Logout">
|
||||
<span class="bi bi-box-arrow-right"></span> Выйти
|
||||
</NavLink>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="Login">
|
||||
<span class="bi bi-box-arrow-in-right"></span> Войти
|
||||
</NavLink>
|
||||
</div>
|
||||
}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private AuthenticationState? _authState;
|
||||
private ClaimsPrincipal? user => _authState?.User;
|
||||
[CascadingParameter] private Task<AuthenticationState>? AuthenticationStateTask { get; set; }
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (AuthenticationStateTask is not null)
|
||||
_authState = await AuthenticationStateTask;
|
||||
}
|
||||
}
|
||||
108
PlaylistShared/Components/Layout/NavMenu.razor.css
Normal file
108
PlaylistShared/Components/Layout/NavMenu.razor.css
Normal file
@@ -0,0 +1,108 @@
|
||||
.navbar-toggler {
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
width: 3.5rem;
|
||||
height: 2.5rem;
|
||||
color: white;
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 1rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") no-repeat center/1.75rem rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.navbar-toggler:checked {
|
||||
background-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.top-row {
|
||||
min-height: 3.5rem;
|
||||
background-color: rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.bi {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
margin-right: 0.75rem;
|
||||
top: -1px;
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
.bi-house-door-fill-nav-menu {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.bi-plus-square-fill-nav-menu {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.bi-list-nested-nav-menu {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
font-size: 0.9rem;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.nav-item:first-of-type {
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.nav-item:last-of-type {
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.nav-item ::deep .nav-link {
|
||||
color: #d7d7d7;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
height: 3rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
line-height: 3rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.nav-item ::deep a.active {
|
||||
background-color: rgba(255,255,255,0.37);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nav-item ::deep .nav-link:hover {
|
||||
background-color: rgba(255,255,255,0.1);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nav-scrollable {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.navbar-toggler:checked ~ .nav-scrollable {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (min-width: 641px) {
|
||||
.navbar-toggler {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-scrollable {
|
||||
/* Never collapse the sidebar for wide screens */
|
||||
display: block;
|
||||
|
||||
/* Allow sidebar to scroll for tall menus */
|
||||
height: calc(100vh - 3.5rem);
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
.bi-box-arrow-right, .bi-box-arrow-in-right {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M10 12.5a.5.5 0 0 1-.5.5h-8a.5.5 0 0 1-.5-.5v-9a.5.5 0 0 1 .5-.5h8a.5.5 0 0 1 .5.5v2a.5.5 0 0 0 1 0v-2A1.5 1.5 0 0 0 9.5 2h-8A1.5 1.5 0 0 0 0 3.5v9A1.5 1.5 0 0 0 1.5 14h8a1.5 1.5 0 0 0 1.5-1.5v-2a.5.5 0 0 0-1 0v2z'/%3E%3Cpath fill-rule='evenodd' d='M15.854 8.354a.5.5 0 0 0 0-.708l-3-3a.5.5 0 0 0-.708.708L14.293 7.5H5.5a.5.5 0 0 0 0 1h8.793l-2.147 2.146a.5.5 0 0 0 .708.708l3-3z'/%3E%3C/svg%3E");
|
||||
}
|
||||
31
PlaylistShared/Components/Layout/ReconnectModal.razor
Normal file
31
PlaylistShared/Components/Layout/ReconnectModal.razor
Normal file
@@ -0,0 +1,31 @@
|
||||
<script type="module" src="@Assets["Components/Layout/ReconnectModal.razor.js"]"></script>
|
||||
|
||||
<dialog id="components-reconnect-modal" data-nosnippet>
|
||||
<div class="components-reconnect-container">
|
||||
<div class="components-rejoining-animation" aria-hidden="true">
|
||||
<div></div>
|
||||
<div></div>
|
||||
</div>
|
||||
<p class="components-reconnect-first-attempt-visible">
|
||||
Rejoining the server...
|
||||
</p>
|
||||
<p class="components-reconnect-repeated-attempt-visible">
|
||||
Rejoin failed... trying again in <span id="components-seconds-to-next-attempt"></span> seconds.
|
||||
</p>
|
||||
<p class="components-reconnect-failed-visible">
|
||||
Failed to rejoin.<br />Please retry or reload the page.
|
||||
</p>
|
||||
<button id="components-reconnect-button" class="components-reconnect-failed-visible">
|
||||
Retry
|
||||
</button>
|
||||
<p class="components-pause-visible">
|
||||
The session has been paused by the server.
|
||||
</p>
|
||||
<p class="components-resume-failed-visible">
|
||||
Failed to resume the session.<br />Please retry or reload the page.
|
||||
</p>
|
||||
<button id="components-resume-button" class="components-pause-visible components-resume-failed-visible">
|
||||
Resume
|
||||
</button>
|
||||
</div>
|
||||
</dialog>
|
||||
157
PlaylistShared/Components/Layout/ReconnectModal.razor.css
Normal file
157
PlaylistShared/Components/Layout/ReconnectModal.razor.css
Normal file
@@ -0,0 +1,157 @@
|
||||
.components-reconnect-first-attempt-visible,
|
||||
.components-reconnect-repeated-attempt-visible,
|
||||
.components-reconnect-failed-visible,
|
||||
.components-pause-visible,
|
||||
.components-resume-failed-visible,
|
||||
.components-rejoining-animation {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#components-reconnect-modal.components-reconnect-show .components-reconnect-first-attempt-visible,
|
||||
#components-reconnect-modal.components-reconnect-show .components-rejoining-animation,
|
||||
#components-reconnect-modal.components-reconnect-paused .components-pause-visible,
|
||||
#components-reconnect-modal.components-reconnect-resume-failed .components-resume-failed-visible,
|
||||
#components-reconnect-modal.components-reconnect-retrying,
|
||||
#components-reconnect-modal.components-reconnect-retrying .components-reconnect-repeated-attempt-visible,
|
||||
#components-reconnect-modal.components-reconnect-retrying .components-rejoining-animation,
|
||||
#components-reconnect-modal.components-reconnect-failed,
|
||||
#components-reconnect-modal.components-reconnect-failed .components-reconnect-failed-visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
||||
#components-reconnect-modal {
|
||||
background-color: white;
|
||||
width: 20rem;
|
||||
margin: 20vh auto;
|
||||
padding: 2rem;
|
||||
border: 0;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 3px 6px 2px rgba(0, 0, 0, 0.3);
|
||||
opacity: 0;
|
||||
transition: display 0.5s allow-discrete, overlay 0.5s allow-discrete;
|
||||
animation: components-reconnect-modal-fadeOutOpacity 0.5s both;
|
||||
&[open]
|
||||
|
||||
{
|
||||
animation: components-reconnect-modal-slideUp 1.5s cubic-bezier(.05, .89, .25, 1.02) 0.3s, components-reconnect-modal-fadeInOpacity 0.5s ease-in-out 0.3s;
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#components-reconnect-modal::backdrop {
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
animation: components-reconnect-modal-fadeInOpacity 0.5s ease-in-out;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@keyframes components-reconnect-modal-slideUp {
|
||||
0% {
|
||||
transform: translateY(30px) scale(0.95);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes components-reconnect-modal-fadeInOpacity {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes components-reconnect-modal-fadeOutOpacity {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.components-reconnect-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
#components-reconnect-modal p {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#components-reconnect-modal button {
|
||||
border: 0;
|
||||
background-color: #6b9ed2;
|
||||
color: white;
|
||||
padding: 4px 24px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
#components-reconnect-modal button:hover {
|
||||
background-color: #3b6ea2;
|
||||
}
|
||||
|
||||
#components-reconnect-modal button:active {
|
||||
background-color: #6b9ed2;
|
||||
}
|
||||
|
||||
.components-rejoining-animation {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
.components-rejoining-animation div {
|
||||
position: absolute;
|
||||
border: 3px solid #0087ff;
|
||||
opacity: 1;
|
||||
border-radius: 50%;
|
||||
animation: components-rejoining-animation 1.5s cubic-bezier(0, 0.2, 0.8, 1) infinite;
|
||||
}
|
||||
|
||||
.components-rejoining-animation div:nth-child(2) {
|
||||
animation-delay: -0.5s;
|
||||
}
|
||||
|
||||
@keyframes components-rejoining-animation {
|
||||
0% {
|
||||
top: 40px;
|
||||
left: 40px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
4.9% {
|
||||
top: 40px;
|
||||
left: 40px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
5% {
|
||||
top: 40px;
|
||||
left: 40px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
top: 0px;
|
||||
left: 0px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
63
PlaylistShared/Components/Layout/ReconnectModal.razor.js
Normal file
63
PlaylistShared/Components/Layout/ReconnectModal.razor.js
Normal file
@@ -0,0 +1,63 @@
|
||||
// Set up event handlers
|
||||
const reconnectModal = document.getElementById("components-reconnect-modal");
|
||||
reconnectModal.addEventListener("components-reconnect-state-changed", handleReconnectStateChanged);
|
||||
|
||||
const retryButton = document.getElementById("components-reconnect-button");
|
||||
retryButton.addEventListener("click", retry);
|
||||
|
||||
const resumeButton = document.getElementById("components-resume-button");
|
||||
resumeButton.addEventListener("click", resume);
|
||||
|
||||
function handleReconnectStateChanged(event) {
|
||||
if (event.detail.state === "show") {
|
||||
reconnectModal.showModal();
|
||||
} else if (event.detail.state === "hide") {
|
||||
reconnectModal.close();
|
||||
} else if (event.detail.state === "failed") {
|
||||
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
|
||||
} else if (event.detail.state === "rejected") {
|
||||
location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
async function retry() {
|
||||
document.removeEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
|
||||
|
||||
try {
|
||||
// Reconnect will asynchronously return:
|
||||
// - true to mean success
|
||||
// - false to mean we reached the server, but it rejected the connection (e.g., unknown circuit ID)
|
||||
// - exception to mean we didn't reach the server (this can be sync or async)
|
||||
const successful = await Blazor.reconnect();
|
||||
if (!successful) {
|
||||
// We have been able to reach the server, but the circuit is no longer available.
|
||||
// We'll reload the page so the user can continue using the app as quickly as possible.
|
||||
const resumeSuccessful = await Blazor.resumeCircuit();
|
||||
if (!resumeSuccessful) {
|
||||
location.reload();
|
||||
} else {
|
||||
reconnectModal.close();
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// We got an exception, server is currently unavailable
|
||||
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
|
||||
}
|
||||
}
|
||||
|
||||
async function resume() {
|
||||
try {
|
||||
const successful = await Blazor.resumeCircuit();
|
||||
if (!successful) {
|
||||
location.reload();
|
||||
}
|
||||
} catch {
|
||||
reconnectModal.classList.replace("components-reconnect-paused", "components-reconnect-resume-failed");
|
||||
}
|
||||
}
|
||||
|
||||
async function retryWhenDocumentBecomesVisible() {
|
||||
if (document.visibilityState === "visible") {
|
||||
await retry();
|
||||
}
|
||||
}
|
||||
80
PlaylistShared/Components/Pages/CreatePlaylist.razor
Normal file
80
PlaylistShared/Components/Pages/CreatePlaylist.razor
Normal file
@@ -0,0 +1,80 @@
|
||||
@page "/createplaylist"
|
||||
@attribute [Authorize]
|
||||
@using System.Security.Claims
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@inject AuthenticationStateProvider AuthenticationStateProvider
|
||||
@inject AppDbContext Db
|
||||
@inject IYandexMusicService YandexService
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
<h3>Создание общего плейлиста</h3>
|
||||
|
||||
<EditForm Model="@model" OnValidSubmit="@HandleSubmit">
|
||||
<div class="mb-3">
|
||||
<label for="title">Название плейлиста</label>
|
||||
<InputText id="title" class="form-control" @bind-Value="model.Title" />
|
||||
<ValidationMessage For="@(() => model.Title)" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="description">Описание (необязательно)</label>
|
||||
<InputText id="description" class="form-control" @bind-Value="model.Description" />
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" disabled="@isLoading">@(isLoading ? "Создание..." : "Создать")</button>
|
||||
</EditForm>
|
||||
|
||||
@code {
|
||||
private CreateModel model = new();
|
||||
private bool isLoading;
|
||||
private string? userId;
|
||||
|
||||
public class CreateModel
|
||||
{
|
||||
[Required]
|
||||
public string Title { get; set; } = "";
|
||||
public string? Description { get; set; }
|
||||
}
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
|
||||
userId = authState.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
}
|
||||
|
||||
private async Task HandleSubmit()
|
||||
{
|
||||
if (string.IsNullOrEmpty(userId))
|
||||
{
|
||||
// пользователь не авторизован
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading = true;
|
||||
try
|
||||
{
|
||||
// 1. Создаём плейлист в Яндекс Музыке
|
||||
var yandexId = await YandexService.CreatePlaylistAsync(userId, model.Title);
|
||||
// 2. Сохраняем в БД
|
||||
var playlist = new SharedPlaylist
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OwnerUserId = userId,
|
||||
YandexPlaylistId = yandexId,
|
||||
Title = model.Title,
|
||||
Description = model.Description,
|
||||
ShareSlug = Guid.NewGuid().ToString("N"),
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
Db.SharedPlaylists.Add(playlist);
|
||||
await Db.SaveChangesAsync();
|
||||
Navigation.NavigateTo("/myplaylists");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// показать ошибку (можно добавить переменную errorMessage)
|
||||
}
|
||||
finally
|
||||
{
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
7
PlaylistShared/Components/Pages/Login.razor
Normal file
7
PlaylistShared/Components/Pages/Login.razor
Normal file
@@ -0,0 +1,7 @@
|
||||
@page "/Login"
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using Microsoft.AspNetCore.Authentication
|
||||
@inject SignInManager<ApplicationUser> SignInManager
|
||||
|
||||
<h3>Вход через Яндекс</h3>
|
||||
<a class="btn btn-primary" href="/signin-yandex">Войти через Яндекс</a>
|
||||
12
PlaylistShared/Components/Pages/Logout.razor
Normal file
12
PlaylistShared/Components/Pages/Logout.razor
Normal file
@@ -0,0 +1,12 @@
|
||||
@page "/Logout"
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@inject SignInManager<ApplicationUser> SignInManager
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
@code {
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await SignInManager.SignOutAsync();
|
||||
Navigation.NavigateTo("/", true);
|
||||
}
|
||||
}
|
||||
57
PlaylistShared/Components/Pages/MyPlaylists.razor
Normal file
57
PlaylistShared/Components/Pages/MyPlaylists.razor
Normal file
@@ -0,0 +1,57 @@
|
||||
@page "/myplaylists"
|
||||
@attribute [Authorize]
|
||||
@using System.Security.Claims
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@inject AuthenticationStateProvider AuthenticationStateProvider
|
||||
@inject AppDbContext Db
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
<h3>Мои плейлисты</h3>
|
||||
<a href="/createplaylist" class="btn btn-success mb-3">Создать новый плейлист</a>
|
||||
|
||||
@if (playlists == null)
|
||||
{
|
||||
<p>Загрузка...</p>
|
||||
}
|
||||
else if (!playlists.Any())
|
||||
{
|
||||
<p>У вас пока нет общих плейлистов. Создайте первый!</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>Название</th><th>Дата создания</th><th>Ссылка</th><th>Настройки</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var pl in playlists)
|
||||
{
|
||||
<tr>
|
||||
<td>@pl.Title</td>
|
||||
<td>@pl.CreatedAt.ToShortDateString()</td>
|
||||
<td><a href="/playlist/@pl.ShareSlug">открыть</a></td>
|
||||
<td><a href="/settings/@pl.Id">настройки</a></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
@code {
|
||||
private List<SharedPlaylist>? playlists;
|
||||
private string? userId;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
|
||||
userId = authState.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
if (userId != null)
|
||||
{
|
||||
playlists = await Db.SharedPlaylists
|
||||
.Where(p => p.OwnerUserId == userId)
|
||||
.OrderByDescending(p => p.CreatedAt)
|
||||
.ToListAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
5
PlaylistShared/Components/Pages/NotFound.razor
Normal file
5
PlaylistShared/Components/Pages/NotFound.razor
Normal file
@@ -0,0 +1,5 @@
|
||||
@page "/not-found"
|
||||
@layout MainLayout
|
||||
|
||||
<h3>Not Found</h3>
|
||||
<p>Sorry, the content you are looking for does not exist.</p>
|
||||
92
PlaylistShared/Components/Pages/PlaylistDetails.razor
Normal file
92
PlaylistShared/Components/Pages/PlaylistDetails.razor
Normal file
@@ -0,0 +1,92 @@
|
||||
@page "/playlist/{slug}"
|
||||
@using System.Security.Claims
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using PlaylistShared.Data.Entities
|
||||
@inject AuthenticationStateProvider AuthenticationStateProvider
|
||||
@inject AppDbContext Db
|
||||
@inject IYandexMusicService YandexService
|
||||
@inject IJSRuntime Js
|
||||
@inject HttpClient Http
|
||||
|
||||
<h3>@playlist?.Title</h3>
|
||||
<p>Владелец: @playlist?.Owner?.UserName</p>
|
||||
<p>Описание: @playlist?.Description</p>
|
||||
|
||||
@if (canAdd)
|
||||
{
|
||||
<div class="mb-3">
|
||||
<input type="text" id="trackId" placeholder="ID трека Яндекс.Музыки" class="form-control" />
|
||||
<input type="text" id="trackTitle" placeholder="Название трека" class="form-control mt-1" />
|
||||
<input type="text" id="artistName" placeholder="Исполнитель" class="form-control mt-1" />
|
||||
<button class="btn btn-primary mt-2" id="addTrackBtn">Добавить</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<ul id="trackList" class="list-group">
|
||||
@foreach (var track in tracks)
|
||||
{
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center" data-track-id="@track.YandexTrackId">
|
||||
<div>
|
||||
<strong>@track.Title</strong> - @track.Artist
|
||||
</div>
|
||||
@if (canDelete(track))
|
||||
{
|
||||
<button class="btn btn-sm btn-danger deleteTrackBtn" data-track-id="@track.YandexTrackId">Удалить</button>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
|
||||
@code {
|
||||
[Parameter] public string slug { get; set; } = "";
|
||||
|
||||
private SharedPlaylist? playlist;
|
||||
private List<PlaylistTrack> tracks = new();
|
||||
private bool canAdd;
|
||||
private string? currentUserId;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
|
||||
currentUserId = authState.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
|
||||
playlist = await Db.SharedPlaylists
|
||||
.Include(p => p.Owner)
|
||||
.Include(p => p.Tracks)
|
||||
.FirstOrDefaultAsync(p => p.ShareSlug == slug);
|
||||
if (playlist == null) return;
|
||||
|
||||
canAdd = playlist.Permissions.Add switch
|
||||
{
|
||||
AccessLevel.All => true,
|
||||
AccessLevel.Authorized => currentUserId != null,
|
||||
_ => false
|
||||
};
|
||||
|
||||
// Здесь можно при желании синхронизировать с Яндекс API, но пока используем локальный кеш
|
||||
// var yandexTracks = await YandexService.GetPlaylistTracksAsync(playlist.OwnerUserId, playlist.YandexPlaylistId);
|
||||
tracks = playlist.Tracks.OrderBy(t => t.AddedAt).ToList();
|
||||
}
|
||||
|
||||
private bool canDelete(PlaylistTrack track)
|
||||
{
|
||||
if (currentUserId == playlist?.OwnerUserId) return true;
|
||||
return playlist?.Permissions.Delete switch
|
||||
{
|
||||
DeleteAccessLevel.All => true,
|
||||
DeleteAccessLevel.Authorized => currentUserId != null,
|
||||
DeleteAccessLevel.AdderOnly => currentUserId != null && currentUserId == track.AddedByUserId,
|
||||
DeleteAccessLevel.OwnerOnly => false,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender && playlist != null)
|
||||
{
|
||||
await Js.InvokeVoidAsync("initPlaylistInteractions", playlist.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
82
PlaylistShared/Components/Pages/PlaylistSettings.razor
Normal file
82
PlaylistShared/Components/Pages/PlaylistSettings.razor
Normal file
@@ -0,0 +1,82 @@
|
||||
@page "/settings/{id:guid}"
|
||||
@attribute [Authorize]
|
||||
@using System.Security.Claims
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using PlaylistShared.Data.Entities
|
||||
@inject AuthenticationStateProvider AuthenticationStateProvider
|
||||
@inject AppDbContext Db
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
<h3>Настройки плейлиста</h3>
|
||||
|
||||
@if (playlist == null)
|
||||
{
|
||||
<p>Загрузка...</p>
|
||||
}
|
||||
else if (!isOwner)
|
||||
{
|
||||
<p>Только владелец может изменять настройки.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<EditForm Model="@playlist.Permissions" OnValidSubmit="@SaveSettings">
|
||||
<div class="mb-3">
|
||||
<label>Кто может просматривать</label>
|
||||
<InputSelect @bind-Value="playlist.Permissions.View" class="form-control">
|
||||
<option value="@AccessLevel.All">Все</option>
|
||||
<option value="@AccessLevel.Authorized">Авторизованные</option>
|
||||
<option value="@AccessLevel.None">Никто</option>
|
||||
</InputSelect>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label>Кто может добавлять треки</label>
|
||||
<InputSelect @bind-Value="playlist.Permissions.Add" class="form-control">
|
||||
<option value="@AccessLevel.All">Все</option>
|
||||
<option value="@AccessLevel.Authorized">Авторизованные</option>
|
||||
<option value="@AccessLevel.None">Никто</option>
|
||||
</InputSelect>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label>Кто может удалять треки</label>
|
||||
<InputSelect @bind-Value="playlist.Permissions.Delete" class="form-control">
|
||||
<option value="@DeleteAccessLevel.All">Все</option>
|
||||
<option value="@DeleteAccessLevel.Authorized">Авторизованные</option>
|
||||
<option value="@DeleteAccessLevel.AdderOnly">Тот, кто добавил</option>
|
||||
<option value="@DeleteAccessLevel.OwnerOnly">Только владелец</option>
|
||||
</InputSelect>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Сохранить</button>
|
||||
</EditForm>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public Guid id { get; set; }
|
||||
private SharedPlaylist? playlist;
|
||||
private bool isOwner;
|
||||
private string? currentUserId;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
|
||||
currentUserId = authState.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
|
||||
playlist = await Db.SharedPlaylists
|
||||
.Include(p => p.Owner)
|
||||
.FirstOrDefaultAsync(p => p.Id == id);
|
||||
if (playlist != null)
|
||||
{
|
||||
isOwner = currentUserId == playlist.OwnerUserId;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SaveSettings()
|
||||
{
|
||||
if (playlist != null)
|
||||
{
|
||||
Db.SharedPlaylists.Update(playlist);
|
||||
await Db.SaveChangesAsync();
|
||||
Navigation.NavigateTo("/myplaylists");
|
||||
}
|
||||
}
|
||||
}
|
||||
6
PlaylistShared/Components/Routes.razor
Normal file
6
PlaylistShared/Components/Routes.razor
Normal file
@@ -0,0 +1,6 @@
|
||||
<Router AppAssembly="typeof(Program).Assembly" NotFoundPage="typeof(Pages.NotFound)">
|
||||
<Found Context="routeData">
|
||||
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
|
||||
<FocusOnNavigate RouteData="routeData" Selector="h1" />
|
||||
</Found>
|
||||
</Router>
|
||||
11
PlaylistShared/Components/_Imports.razor
Normal file
11
PlaylistShared/Components/_Imports.razor
Normal file
@@ -0,0 +1,11 @@
|
||||
@using System.Net.Http
|
||||
@using System.Net.Http.Json
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||
@using Microsoft.JSInterop
|
||||
@using PlaylistShared
|
||||
@using PlaylistShared.Components
|
||||
@using PlaylistShared.Components.Layout
|
||||
109
PlaylistShared/Controllers/AuthController.cs
Normal file
109
PlaylistShared/Controllers/AuthController.cs
Normal file
@@ -0,0 +1,109 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using PlaylistShared.Models;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace PlaylistShared.Controllers;
|
||||
|
||||
[Microsoft.AspNetCore.Mvc.Route("auth")]
|
||||
[ApiController]
|
||||
public class AuthController : ControllerBase
|
||||
{
|
||||
private readonly IConfiguration _config;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly AppDbContext _db;
|
||||
private readonly SignInManager<ApplicationUser> _signInManager;
|
||||
private readonly ILogger<AuthController> _logger;
|
||||
|
||||
public AuthController(
|
||||
IConfiguration config,
|
||||
IHttpClientFactory factory,
|
||||
AppDbContext db,
|
||||
SignInManager<ApplicationUser> signInManager,
|
||||
ILogger<AuthController> logger)
|
||||
{
|
||||
_config = config;
|
||||
_httpClient = factory.CreateClient();
|
||||
_db = db;
|
||||
_signInManager = signInManager;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpGet("login")]
|
||||
public IActionResult Login()
|
||||
{
|
||||
var clientId = _config["YandexOAuth:ClientId"];
|
||||
var redirectUri = Url.Action(nameof(Callback), "Auth", null, Request.Scheme);
|
||||
var authUrl = $"{_config["YandexOAuth:AuthorizationEndpoint"]}?response_type=code&client_id={clientId}&redirect_uri={Uri.EscapeDataString(redirectUri!)}";
|
||||
return Redirect(authUrl);
|
||||
}
|
||||
|
||||
[HttpGet("callback")]
|
||||
public async Task<IActionResult> Callback(string code)
|
||||
{
|
||||
if (string.IsNullOrEmpty(code))
|
||||
return BadRequest("Missing code");
|
||||
|
||||
var tokenResponse = await ExchangeCodeForTokenAsync(code);
|
||||
if (tokenResponse is null)
|
||||
return StatusCode(500, "Token exchange failed");
|
||||
|
||||
var userInfo = await GetUserInfoAsync(tokenResponse.AccessToken);
|
||||
if (userInfo is null)
|
||||
return StatusCode(500, "User info fetch failed");
|
||||
|
||||
var user = await _db.Users.FirstOrDefaultAsync(u => u.YandexId == userInfo.Id);
|
||||
if (user is null)
|
||||
{
|
||||
user = new ApplicationUser
|
||||
{
|
||||
UserName = userInfo.Login,
|
||||
YandexId = userInfo.Id,
|
||||
Email = userInfo.DefaultEmail,
|
||||
AccessToken = tokenResponse.AccessToken,
|
||||
RefreshToken = tokenResponse.RefreshToken,
|
||||
AccessTokenExpiresAt = DateTime.UtcNow.AddSeconds(tokenResponse.ExpiresIn)
|
||||
};
|
||||
_db.Users.Add(user);
|
||||
}
|
||||
else
|
||||
{
|
||||
user.AccessToken = tokenResponse.AccessToken;
|
||||
user.RefreshToken = tokenResponse.RefreshToken;
|
||||
user.AccessTokenExpiresAt = DateTime.UtcNow.AddSeconds(tokenResponse.ExpiresIn);
|
||||
_db.Users.Update(user);
|
||||
}
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
await _signInManager.SignInAsync(user, isPersistent: false);
|
||||
return Redirect("/");
|
||||
}
|
||||
|
||||
private async Task<YandexTokenResponse?> ExchangeCodeForTokenAsync(string code)
|
||||
{
|
||||
var tokenUrl = _config["YandexOAuth:TokenEndpoint"];
|
||||
var redirectUri = Url.Action(nameof(Callback), "Auth", null, Request.Scheme);
|
||||
var content = new FormUrlEncodedContent(new[]
|
||||
{
|
||||
new KeyValuePair<string, string>("grant_type", "authorization_code"),
|
||||
new KeyValuePair<string, string>("code", code),
|
||||
new KeyValuePair<string, string>("client_id", _config["YandexOAuth:ClientId"]!),
|
||||
new KeyValuePair<string, string>("client_secret", _config["YandexOAuth:ClientSecret"]!),
|
||||
new KeyValuePair<string, string>("redirect_uri", redirectUri!)
|
||||
});
|
||||
|
||||
var response = await _httpClient.PostAsync(tokenUrl!, content);
|
||||
if (!response.IsSuccessStatusCode) return null;
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
return JsonSerializer.Deserialize<YandexTokenResponse>(json);
|
||||
}
|
||||
|
||||
private async Task<YandexUserInfo?> GetUserInfoAsync(string accessToken)
|
||||
{
|
||||
_httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("OAuth", accessToken);
|
||||
var response = await _httpClient.GetAsync(_config["YandexOAuth:UserInfoEndpoint"]!);
|
||||
if (!response.IsSuccessStatusCode) return null;
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
return JsonSerializer.Deserialize<YandexUserInfo>(json);
|
||||
}
|
||||
}
|
||||
93
PlaylistShared/Controllers/TrackController.cs
Normal file
93
PlaylistShared/Controllers/TrackController.cs
Normal file
@@ -0,0 +1,93 @@
|
||||
// Controllers/TrackController.cs
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace PlaylistShared.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Microsoft.AspNetCore.Mvc.Route("api/tracks")]
|
||||
[Authorize]
|
||||
public class TrackController : ControllerBase
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IYandexMusicService _yandex;
|
||||
|
||||
public TrackController(AppDbContext db, IYandexMusicService yandex)
|
||||
{
|
||||
_db = db;
|
||||
_yandex = yandex;
|
||||
}
|
||||
|
||||
[HttpPost("add")]
|
||||
public async Task<IActionResult> AddTrack(Guid playlistId, string trackId, string? trackTitle, string? artist)
|
||||
{
|
||||
var playlist = await _db.SharedPlaylists
|
||||
.Include(p => p.Owner)
|
||||
.FirstOrDefaultAsync(p => p.Id == playlistId);
|
||||
if (playlist == null) return NotFound();
|
||||
|
||||
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (!CanAdd(playlist, userId)) return Forbid();
|
||||
|
||||
// Добавляем в Яндекс
|
||||
await _yandex.AddTrackToPlaylistAsync(playlist.OwnerUserId, playlist.YandexPlaylistId, trackId);
|
||||
|
||||
// Сохраняем информацию о треке в кеш
|
||||
var track = new PlaylistTrack
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
PlaylistId = playlist.Id,
|
||||
YandexTrackId = trackId,
|
||||
Title = trackTitle ?? "Unknown",
|
||||
Artist = artist,
|
||||
AddedByUserId = userId,
|
||||
AddedAt = DateTime.UtcNow
|
||||
};
|
||||
_db.PlaylistTracks.Add(track);
|
||||
await _db.SaveChangesAsync();
|
||||
return Ok();
|
||||
}
|
||||
|
||||
[HttpDelete("remove")]
|
||||
public async Task<IActionResult> RemoveTrack(Guid playlistId, string trackId)
|
||||
{
|
||||
var playlist = await _db.SharedPlaylists
|
||||
.Include(p => p.Owner)
|
||||
.FirstOrDefaultAsync(p => p.Id == playlistId);
|
||||
if (playlist == null) return NotFound();
|
||||
|
||||
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
var trackEntry = await _db.PlaylistTracks
|
||||
.FirstOrDefaultAsync(t => t.PlaylistId == playlistId && t.YandexTrackId == trackId);
|
||||
if (!CanDelete(playlist, userId, trackEntry?.AddedByUserId))
|
||||
return Forbid();
|
||||
|
||||
await _yandex.RemoveTrackFromPlaylistAsync(playlist.OwnerUserId, playlist.YandexPlaylistId, trackId);
|
||||
if (trackEntry != null) _db.PlaylistTracks.Remove(trackEntry);
|
||||
await _db.SaveChangesAsync();
|
||||
return Ok();
|
||||
}
|
||||
|
||||
private bool CanAdd(SharedPlaylist playlist, string? userId)
|
||||
{
|
||||
return playlist.Permissions.Add switch
|
||||
{
|
||||
AccessLevel.All => true,
|
||||
AccessLevel.Authorized => userId != null,
|
||||
AccessLevel.None => false,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
private bool CanDelete(SharedPlaylist playlist, string? userId, string? adderUserId)
|
||||
{
|
||||
if (userId == playlist.OwnerUserId) return true; // владелец всегда может удалить
|
||||
return playlist.Permissions.Delete switch
|
||||
{
|
||||
DeleteAccessLevel.All => true,
|
||||
DeleteAccessLevel.Authorized => userId != null,
|
||||
DeleteAccessLevel.AdderOnly => userId != null && userId == adderUserId,
|
||||
DeleteAccessLevel.OwnerOnly => false,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
}
|
||||
37
PlaylistShared/Data/Contexts/AppDbContext.cs
Normal file
37
PlaylistShared/Data/Contexts/AppDbContext.cs
Normal file
@@ -0,0 +1,37 @@
|
||||
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PlaylistShared.Data.Entities;
|
||||
|
||||
namespace PlaylistShared.Data.Contexts;
|
||||
|
||||
public class AppDbContext : IdentityDbContext<ApplicationUser>
|
||||
{
|
||||
public DbSet<SharedPlaylist> SharedPlaylists => Set<SharedPlaylist>();
|
||||
public DbSet<PlaylistTrack> PlaylistTracks => Set<PlaylistTrack>();
|
||||
|
||||
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder builder)
|
||||
{
|
||||
base.OnModelCreating(builder);
|
||||
|
||||
builder.Entity<SharedPlaylist>(entity =>
|
||||
{
|
||||
entity.HasIndex(p => p.ShareSlug).IsUnique();
|
||||
entity.OwnsOne(p => p.Permissions, per =>
|
||||
{
|
||||
per.Property(x => x.View).HasColumnName("PermView");
|
||||
per.Property(x => x.Add).HasColumnName("PermAdd");
|
||||
per.Property(x => x.Delete).HasColumnName("PermDelete");
|
||||
});
|
||||
});
|
||||
|
||||
builder.Entity<PlaylistTrack>(entity =>
|
||||
{
|
||||
entity.HasOne(pt => pt.Playlist)
|
||||
.WithMany(p => p.Tracks)
|
||||
.HasForeignKey(pt => pt.PlaylistId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
}
|
||||
}
|
||||
10
PlaylistShared/Data/Entities/ApplicationUser.cs
Normal file
10
PlaylistShared/Data/Entities/ApplicationUser.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace PlaylistShared.Data.Entities;
|
||||
|
||||
public class ApplicationUser : IdentityUser
|
||||
{
|
||||
public string? YandexId { get; set; }
|
||||
public string? AccessToken { get; set; }
|
||||
public string? RefreshToken { get; set; }
|
||||
public DateTime? AccessTokenExpiresAt { get; set; }
|
||||
public string? AvatarUrl { get; set; }
|
||||
}
|
||||
14
PlaylistShared/Data/Entities/PlaylistPermissions.cs
Normal file
14
PlaylistShared/Data/Entities/PlaylistPermissions.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace PlaylistShared.Data.Entities;
|
||||
|
||||
public enum AccessLevel { All, Authorized, None }
|
||||
public enum DeleteAccessLevel { All, Authorized, AdderOnly, OwnerOnly }
|
||||
|
||||
[Owned]
|
||||
public class PlaylistPermissions
|
||||
{
|
||||
public AccessLevel View { get; set; } = AccessLevel.All;
|
||||
public AccessLevel Add { get; set; } = AccessLevel.Authorized;
|
||||
public DeleteAccessLevel Delete { get; set; } = DeleteAccessLevel.OwnerOnly;
|
||||
}
|
||||
23
PlaylistShared/Data/Entities/PlaylistTrack.cs
Normal file
23
PlaylistShared/Data/Entities/PlaylistTrack.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace PlaylistShared.Data.Entities;
|
||||
|
||||
public class PlaylistTrack
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid PlaylistId { get; set; }
|
||||
public SharedPlaylist Playlist { get; set; } = null!;
|
||||
|
||||
public string YandexTrackId { get; set; } = null!;
|
||||
public string Title { get; set; } = null!;
|
||||
public string? Artist { get; set; }
|
||||
public string? AlbumTitle { get; set; }
|
||||
public int DurationMs { get; set; }
|
||||
|
||||
public string? AddedByUserId { get; set; }
|
||||
|
||||
[ForeignKey(nameof(AddedByUserId))]
|
||||
public ApplicationUser? AddedByUser { get; set; }
|
||||
|
||||
public DateTime AddedAt { get; set; }
|
||||
}
|
||||
28
PlaylistShared/Data/Entities/SharedPlaylist.cs
Normal file
28
PlaylistShared/Data/Entities/SharedPlaylist.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace PlaylistShared.Data.Entities;
|
||||
|
||||
public class SharedPlaylist
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public string OwnerUserId { get; set; } = null!;
|
||||
[ForeignKey(nameof(OwnerUserId))]
|
||||
public ApplicationUser Owner { get; set; } = null!;
|
||||
|
||||
public string YandexPlaylistId { get; set; } = null!;
|
||||
public string Title { get; set; } = null!;
|
||||
public string? Description { get; set; }
|
||||
|
||||
[Required]
|
||||
public string ShareSlug { get; set; } = null!;
|
||||
|
||||
public PlaylistPermissions Permissions { get; set; } = new();
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
|
||||
public List<PlaylistTrack> Tracks { get; set; } = new();
|
||||
}
|
||||
9
PlaylistShared/GlobalUsings.cs
Normal file
9
PlaylistShared/GlobalUsings.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
global using Microsoft.AspNetCore.Authorization;
|
||||
global using Microsoft.AspNetCore.Components;
|
||||
global using Microsoft.AspNetCore.Identity;
|
||||
global using Microsoft.EntityFrameworkCore;
|
||||
global using PlaylistShared.Data.Contexts;
|
||||
global using PlaylistShared.Data.Entities;
|
||||
global using PlaylistShared.Services;
|
||||
global using System.ComponentModel.DataAnnotations;
|
||||
global using System.Security.Claims;
|
||||
4
PlaylistShared/Models/YandexOAuthModels.cs
Normal file
4
PlaylistShared/Models/YandexOAuthModels.cs
Normal file
@@ -0,0 +1,4 @@
|
||||
namespace PlaylistShared.Models;
|
||||
|
||||
public record YandexTokenResponse(string AccessToken, string RefreshToken, int ExpiresIn);
|
||||
public record YandexUserInfo(string Id, string Login, string? DisplayName, string? DefaultEmail, string? AvatarUrl);
|
||||
26
PlaylistShared/PlaylistShared.csproj
Normal file
26
PlaylistShared/PlaylistShared.csproj
Normal file
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.5" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\YandexMusic\YandexMusic.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Data\Migrations\" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
79
PlaylistShared/Program.cs
Normal file
79
PlaylistShared/Program.cs
Normal file
@@ -0,0 +1,79 @@
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PlaylistShared.Data.Contexts;
|
||||
using PlaylistShared.Data.Entities;
|
||||
using PlaylistShared.Services;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddRazorPages();
|
||||
builder.Services.AddServerSideBlazor();
|
||||
|
||||
builder.Services.AddDbContext<AppDbContext>(options =>
|
||||
{
|
||||
options.UseSqlite(builder.Configuration.GetConnectionString("DefaultConnection"));
|
||||
});
|
||||
|
||||
builder.Services.AddIdentity<ApplicationUser, IdentityRole>()
|
||||
.AddEntityFrameworkStores<AppDbContext>()
|
||||
.AddDefaultTokenProviders();
|
||||
|
||||
builder.Services.ConfigureApplicationCookie(options =>
|
||||
{
|
||||
options.LoginPath = "/Login";
|
||||
options.LogoutPath = "/Logout";
|
||||
options.AccessDeniedPath = "/AccessDenied";
|
||||
});
|
||||
|
||||
builder.Services.AddAuthentication()
|
||||
.AddOAuth("Yandex", options =>
|
||||
{
|
||||
options.ClientId = builder.Configuration["YandexOAuth:ClientId"];
|
||||
options.ClientSecret = builder.Configuration["YandexOAuth:ClientSecret"];
|
||||
options.AuthorizationEndpoint = builder.Configuration["YandexOAuth:AuthorizationEndpoint"];
|
||||
options.TokenEndpoint = builder.Configuration["YandexOAuth:TokenEndpoint"];
|
||||
options.UserInformationEndpoint = builder.Configuration["YandexOAuth:UserInfoEndpoint"];
|
||||
options.CallbackPath = "/signin-yandex";
|
||||
|
||||
options.ClaimActions.MapJsonKey("urn:yandex:avatar_url", "avatar_url");
|
||||
options.ClaimActions.MapJsonKey("urn:yandex:display_name", "display_name");
|
||||
|
||||
options.Events = new Microsoft.AspNetCore.Authentication.OAuth.OAuthEvents
|
||||
{
|
||||
OnCreatingTicket = async context =>
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, context.Options.UserInformationEndpoint);
|
||||
request.Headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json"));
|
||||
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", context.AccessToken);
|
||||
var response = await context.Backchannel.SendAsync(request);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var user = System.Text.Json.JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
context.RunClaimActions(user.RootElement);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
builder.Services.AddScoped<IYandexMusicService, YandexMusicService>();
|
||||
builder.Services.AddHttpClient();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
if (!app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseExceptionHandler("/Error");
|
||||
app.UseHsts();
|
||||
}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
app.UseStaticFiles();
|
||||
app.UseRouting();
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapRazorPages();
|
||||
app.MapBlazorHub();
|
||||
app.MapFallbackToPage("/_Host");
|
||||
|
||||
app.Run();
|
||||
23
PlaylistShared/Properties/launchSettings.json
Normal file
23
PlaylistShared/Properties/launchSettings.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "http://localhost:5058",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "https://localhost:7272;http://localhost:5058",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
14
PlaylistShared/Services/IYandexMusicService.cs
Normal file
14
PlaylistShared/Services/IYandexMusicService.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
namespace PlaylistShared.Services;
|
||||
|
||||
public interface IYandexMusicService
|
||||
{
|
||||
Task<string> CreatePlaylistAsync(string userId, string title);
|
||||
Task AddTrackToPlaylistAsync(string userId, string yandexPlaylistId, string trackId);
|
||||
Task RemoveTrackFromPlaylistAsync(string userId, string yandexPlaylistId, string trackId);
|
||||
Task<List<YandexTrackInfo>> GetPlaylistTracksAsync(string userId, string yandexPlaylistId);
|
||||
Task<YandexPlaylistInfo> GetPlaylistInfoAsync(string userId, string yandexPlaylistId);
|
||||
Task<string?> RefreshUserTokenAsync(string userId);
|
||||
}
|
||||
|
||||
public record YandexTrackInfo(string Id, string Title, string? Artist, string? AlbumTitle, int DurationMs);
|
||||
public record YandexPlaylistInfo(string Kind, string Title, int TrackCount);
|
||||
173
PlaylistShared/Services/YandexMusicService.cs
Normal file
173
PlaylistShared/Services/YandexMusicService.cs
Normal file
@@ -0,0 +1,173 @@
|
||||
using PlaylistShared.Data.Contexts;
|
||||
using PlaylistShared.Data.Entities;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace PlaylistShared.Services;
|
||||
|
||||
public class YandexMusicService : IYandexMusicService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly HttpClient _http;
|
||||
private readonly IConfiguration _config;
|
||||
private readonly ILogger<YandexMusicService> _logger;
|
||||
|
||||
public YandexMusicService(AppDbContext db, IHttpClientFactory factory, IConfiguration config, ILogger<YandexMusicService> logger)
|
||||
{
|
||||
_db = db;
|
||||
_http = factory.CreateClient();
|
||||
_config = config;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
private async Task<ApplicationUser> GetUserWithValidTokenAsync(string userId)
|
||||
{
|
||||
var user = await _db.Users.FindAsync(userId);
|
||||
if (user == null) throw new Exception("User not found");
|
||||
|
||||
if (user.AccessTokenExpiresAt <= DateTime.UtcNow.AddMinutes(5))
|
||||
{
|
||||
var newToken = await RefreshYandexTokenAsync(user.RefreshToken!);
|
||||
user.AccessToken = newToken.access_token;
|
||||
user.RefreshToken = newToken.refresh_token;
|
||||
user.AccessTokenExpiresAt = DateTime.UtcNow.AddSeconds(newToken.expires_in);
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
private async Task<(string access_token, string refresh_token, int expires_in)> RefreshYandexTokenAsync(string refreshToken)
|
||||
{
|
||||
var content = new FormUrlEncodedContent(new[]
|
||||
{
|
||||
new KeyValuePair<string, string>("grant_type", "refresh_token"),
|
||||
new KeyValuePair<string, string>("refresh_token", refreshToken),
|
||||
new KeyValuePair<string, string>("client_id", _config["YandexOAuth:ClientId"]),
|
||||
new KeyValuePair<string, string>("client_secret", _config["YandexOAuth:ClientSecret"])
|
||||
});
|
||||
var response = await _http.PostAsync(_config["YandexOAuth:TokenEndpoint"], content);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
return (
|
||||
doc.RootElement.GetProperty("access_token").GetString()!,
|
||||
doc.RootElement.GetProperty("refresh_token").GetString()!,
|
||||
doc.RootElement.GetProperty("expires_in").GetInt32()
|
||||
);
|
||||
}
|
||||
|
||||
public async Task<string> CreatePlaylistAsync(string userId, string title)
|
||||
{
|
||||
var user = await GetUserWithValidTokenAsync(userId);
|
||||
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("OAuth", user.AccessToken);
|
||||
var payload = new { title, visibility = "public" };
|
||||
var json = JsonSerializer.Serialize(payload);
|
||||
var response = await _http.PostAsync("https://api.music.yandex.net/users/self/playlists/create",
|
||||
new StringContent(json, Encoding.UTF8, "application/json"));
|
||||
response.EnsureSuccessStatusCode();
|
||||
var resultJson = await response.Content.ReadAsStringAsync();
|
||||
var result = JsonSerializer.Deserialize<YandexApiResponse<YandexPlaylistData>>(resultJson);
|
||||
return result!.Result.Kind;
|
||||
}
|
||||
|
||||
public async Task AddTrackToPlaylistAsync(string userId, string yandexPlaylistId, string trackId)
|
||||
{
|
||||
var user = await GetUserWithValidTokenAsync(userId);
|
||||
var uid = user.YandexId; // или user.Id? В API Яндекса используется uid из аккаунта, но можно использовать "self"
|
||||
// Получаем альбом трека (опционально, можно передать без albumId)
|
||||
// Для простоты используем трек без albumId
|
||||
var diff = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
op = "insert",
|
||||
at = 0,
|
||||
tracks = new[] { new { id = trackId } }
|
||||
}
|
||||
};
|
||||
var json = JsonSerializer.Serialize(diff);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
var url = $"https://api.music.yandex.net/users/{uid}/playlists/{yandexPlaylistId}/change";
|
||||
var response = await _http.PostAsync(url, content);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
public async Task RemoveTrackFromPlaylistAsync(string userId, string yandexPlaylistId, string trackId)
|
||||
{
|
||||
var user = await GetUserWithValidTokenAsync(userId);
|
||||
var uid = user.YandexId;
|
||||
// Сначала нужно получить позицию трека в плейлисте – упростим: удаляем по индексу (не надёжно)
|
||||
// Лучше получить плейлист, найти индекс трека, потом удалить
|
||||
var playlist = await GetPlaylistInfoAsync(userId, yandexPlaylistId);
|
||||
var tracks = await GetPlaylistTracksAsync(userId, yandexPlaylistId);
|
||||
var index = tracks.FindIndex(t => t.Id == trackId);
|
||||
if (index == -1) throw new Exception("Track not found in playlist");
|
||||
var diff = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
op = "delete",
|
||||
from = index,
|
||||
to = index + 1
|
||||
}
|
||||
};
|
||||
var json = JsonSerializer.Serialize(diff);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
var url = $"https://api.music.yandex.net/users/{uid}/playlists/{yandexPlaylistId}/change";
|
||||
var response = await _http.PostAsync(url, content);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
public async Task<List<YandexTrackInfo>> GetPlaylistTracksAsync(string userId, string yandexPlaylistId)
|
||||
{
|
||||
var user = await GetUserWithValidTokenAsync(userId);
|
||||
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("OAuth", user.AccessToken);
|
||||
var uid = user.YandexId;
|
||||
var url = $"https://api.music.yandex.net/users/{uid}/playlists/{yandexPlaylistId}";
|
||||
var response = await _http.GetAsync(url);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var data = JsonSerializer.Deserialize<YandexApiResponse<YandexPlaylistFull>>(json);
|
||||
return data!.Result.Tracks?.Select(t => new YandexTrackInfo(
|
||||
t.Id.ToString(),
|
||||
t.Title,
|
||||
t.Artists?.FirstOrDefault()?.Name,
|
||||
t.Albums?.FirstOrDefault()?.Title,
|
||||
t.DurationMs
|
||||
)).ToList() ?? new();
|
||||
}
|
||||
|
||||
public async Task<YandexPlaylistInfo> GetPlaylistInfoAsync(string userId, string yandexPlaylistId)
|
||||
{
|
||||
var user = await GetUserWithValidTokenAsync(userId);
|
||||
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("OAuth", user.AccessToken);
|
||||
var uid = user.YandexId;
|
||||
var url = $"https://api.music.yandex.net/users/{uid}/playlists/{yandexPlaylistId}";
|
||||
var response = await _http.GetAsync(url);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var data = JsonSerializer.Deserialize<YandexApiResponse<YandexPlaylistFull>>(json);
|
||||
return new YandexPlaylistInfo(data!.Result.Kind, data.Result.Title, data.Result.TrackCount);
|
||||
}
|
||||
|
||||
public async Task<string?> RefreshUserTokenAsync(string userId)
|
||||
{
|
||||
var user = await _db.Users.FindAsync(userId);
|
||||
if (user?.RefreshToken == null) return null;
|
||||
var newToken = await RefreshYandexTokenAsync(user.RefreshToken);
|
||||
user.AccessToken = newToken.access_token;
|
||||
user.RefreshToken = newToken.refresh_token;
|
||||
user.AccessTokenExpiresAt = DateTime.UtcNow.AddSeconds(newToken.expires_in);
|
||||
await _db.SaveChangesAsync();
|
||||
return newToken.access_token;
|
||||
}
|
||||
|
||||
// Вспомогательные record
|
||||
private record YandexApiResponse<T>(T Result);
|
||||
private record YandexPlaylistData(string Kind);
|
||||
private record YandexPlaylistFull(string Kind, string Title, int TrackCount, List<YandexTrack> Tracks);
|
||||
private record YandexTrack(string Id, string Title, int DurationMs, List<YandexArtist> Artists, List<YandexAlbum> Albums);
|
||||
private record YandexArtist(string Name);
|
||||
private record YandexAlbum(string Title);
|
||||
}
|
||||
8
PlaylistShared/appsettings.Development.json
Normal file
8
PlaylistShared/appsettings.Development.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
24
PlaylistShared/appsettings.json
Normal file
24
PlaylistShared/appsettings.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"YandexOAuth": {
|
||||
"ClientId": "0916685f8a3641ca8fc382dbccf77236",
|
||||
"ClientSecret": "f7398893cd814f8b84b85aeb2a0a6698",
|
||||
"AuthorizationEndpoint": "https://oauth.yandex.ru/authorize",
|
||||
"TokenEndpoint": "https://oauth.yandex.ru/token",
|
||||
"UserInfoEndpoint": "https://login.yandex.ru/info"
|
||||
},
|
||||
"Jwt": {
|
||||
"Secret": "very_long_secret_key_at_least_32_chars",
|
||||
"Issuer": "PlaylistShared",
|
||||
"Audience": "PlaylistSharedAPI"
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Data Source=playlist.db"
|
||||
}
|
||||
}
|
||||
60
PlaylistShared/wwwroot/app.css
Normal file
60
PlaylistShared/wwwroot/app.css
Normal file
@@ -0,0 +1,60 @@
|
||||
html, body {
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
a, .btn-link {
|
||||
color: #006bb7;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
color: #fff;
|
||||
background-color: #1b6ec2;
|
||||
border-color: #1861ac;
|
||||
}
|
||||
|
||||
.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus {
|
||||
box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding-top: 1.1rem;
|
||||
}
|
||||
|
||||
h1:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.valid.modified:not([type=checkbox]) {
|
||||
outline: 1px solid #26b050;
|
||||
}
|
||||
|
||||
.invalid {
|
||||
outline: 1px solid #e50000;
|
||||
}
|
||||
|
||||
.validation-message {
|
||||
color: #e50000;
|
||||
}
|
||||
|
||||
.blazor-error-boundary {
|
||||
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
|
||||
padding: 1rem 1rem 1rem 3.7rem;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.blazor-error-boundary::after {
|
||||
content: "An error has occurred."
|
||||
}
|
||||
|
||||
.darker-border-checkbox.form-check-input {
|
||||
border-color: #929292;
|
||||
}
|
||||
|
||||
.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder {
|
||||
color: var(--bs-secondary-color);
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder {
|
||||
text-align: start;
|
||||
}
|
||||
BIN
PlaylistShared/wwwroot/favicon.png
Normal file
BIN
PlaylistShared/wwwroot/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
26
PlaylistShared/wwwroot/js/site.js
Normal file
26
PlaylistShared/wwwroot/js/site.js
Normal file
@@ -0,0 +1,26 @@
|
||||
window.initPlaylistInteractions = (playlistId) => {
|
||||
const addBtn = document.getElementById('addTrackBtn');
|
||||
if (addBtn) {
|
||||
addBtn.addEventListener('click', async () => {
|
||||
const trackId = document.getElementById('trackId').value;
|
||||
const trackTitle = document.getElementById('trackTitle').value;
|
||||
const artist = document.getElementById('artistName').value;
|
||||
const response = await fetch(`/api/tracks/add?playlistId=${playlistId}&trackId=${trackId}&trackTitle=${encodeURIComponent(trackTitle)}&artist=${encodeURIComponent(artist)}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
if (response.ok) location.reload();
|
||||
else alert('Ошибка добавления трека');
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll('.deleteTrackBtn').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const trackId = btn.getAttribute('data-track-id');
|
||||
const response = await fetch(`/api/tracks/remove?playlistId=${playlistId}&trackId=${trackId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (response.ok) location.reload();
|
||||
else alert('Ошибка удаления трека');
|
||||
});
|
||||
});
|
||||
};
|
||||
4085
PlaylistShared/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css
vendored
Normal file
4085
PlaylistShared/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css
vendored
Normal file
File diff suppressed because it is too large
Load Diff
1
PlaylistShared/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css.map
vendored
Normal file
1
PlaylistShared/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css.map
vendored
Normal file
File diff suppressed because one or more lines are too long
6
PlaylistShared/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css
vendored
Normal file
6
PlaylistShared/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
PlaylistShared/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css.map
vendored
Normal file
1
PlaylistShared/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css.map
vendored
Normal file
File diff suppressed because one or more lines are too long
4084
PlaylistShared/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css
vendored
Normal file
4084
PlaylistShared/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css
vendored
Normal file
File diff suppressed because it is too large
Load Diff
1
PlaylistShared/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css.map
vendored
Normal file
1
PlaylistShared/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css.map
vendored
Normal file
File diff suppressed because one or more lines are too long
6
PlaylistShared/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css
vendored
Normal file
6
PlaylistShared/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
PlaylistShared/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css.map
vendored
Normal file
1
PlaylistShared/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css.map
vendored
Normal file
File diff suppressed because one or more lines are too long
597
PlaylistShared/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css
vendored
Normal file
597
PlaylistShared/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css
vendored
Normal file
@@ -0,0 +1,597 @@
|
||||
/*!
|
||||
* Bootstrap Reboot v5.3.3 (https://getbootstrap.com/)
|
||||
* Copyright 2011-2024 The Bootstrap Authors
|
||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
|
||||
*/
|
||||
:root,
|
||||
[data-bs-theme=light] {
|
||||
--bs-blue: #0d6efd;
|
||||
--bs-indigo: #6610f2;
|
||||
--bs-purple: #6f42c1;
|
||||
--bs-pink: #d63384;
|
||||
--bs-red: #dc3545;
|
||||
--bs-orange: #fd7e14;
|
||||
--bs-yellow: #ffc107;
|
||||
--bs-green: #198754;
|
||||
--bs-teal: #20c997;
|
||||
--bs-cyan: #0dcaf0;
|
||||
--bs-black: #000;
|
||||
--bs-white: #fff;
|
||||
--bs-gray: #6c757d;
|
||||
--bs-gray-dark: #343a40;
|
||||
--bs-gray-100: #f8f9fa;
|
||||
--bs-gray-200: #e9ecef;
|
||||
--bs-gray-300: #dee2e6;
|
||||
--bs-gray-400: #ced4da;
|
||||
--bs-gray-500: #adb5bd;
|
||||
--bs-gray-600: #6c757d;
|
||||
--bs-gray-700: #495057;
|
||||
--bs-gray-800: #343a40;
|
||||
--bs-gray-900: #212529;
|
||||
--bs-primary: #0d6efd;
|
||||
--bs-secondary: #6c757d;
|
||||
--bs-success: #198754;
|
||||
--bs-info: #0dcaf0;
|
||||
--bs-warning: #ffc107;
|
||||
--bs-danger: #dc3545;
|
||||
--bs-light: #f8f9fa;
|
||||
--bs-dark: #212529;
|
||||
--bs-primary-rgb: 13, 110, 253;
|
||||
--bs-secondary-rgb: 108, 117, 125;
|
||||
--bs-success-rgb: 25, 135, 84;
|
||||
--bs-info-rgb: 13, 202, 240;
|
||||
--bs-warning-rgb: 255, 193, 7;
|
||||
--bs-danger-rgb: 220, 53, 69;
|
||||
--bs-light-rgb: 248, 249, 250;
|
||||
--bs-dark-rgb: 33, 37, 41;
|
||||
--bs-primary-text-emphasis: #052c65;
|
||||
--bs-secondary-text-emphasis: #2b2f32;
|
||||
--bs-success-text-emphasis: #0a3622;
|
||||
--bs-info-text-emphasis: #055160;
|
||||
--bs-warning-text-emphasis: #664d03;
|
||||
--bs-danger-text-emphasis: #58151c;
|
||||
--bs-light-text-emphasis: #495057;
|
||||
--bs-dark-text-emphasis: #495057;
|
||||
--bs-primary-bg-subtle: #cfe2ff;
|
||||
--bs-secondary-bg-subtle: #e2e3e5;
|
||||
--bs-success-bg-subtle: #d1e7dd;
|
||||
--bs-info-bg-subtle: #cff4fc;
|
||||
--bs-warning-bg-subtle: #fff3cd;
|
||||
--bs-danger-bg-subtle: #f8d7da;
|
||||
--bs-light-bg-subtle: #fcfcfd;
|
||||
--bs-dark-bg-subtle: #ced4da;
|
||||
--bs-primary-border-subtle: #9ec5fe;
|
||||
--bs-secondary-border-subtle: #c4c8cb;
|
||||
--bs-success-border-subtle: #a3cfbb;
|
||||
--bs-info-border-subtle: #9eeaf9;
|
||||
--bs-warning-border-subtle: #ffe69c;
|
||||
--bs-danger-border-subtle: #f1aeb5;
|
||||
--bs-light-border-subtle: #e9ecef;
|
||||
--bs-dark-border-subtle: #adb5bd;
|
||||
--bs-white-rgb: 255, 255, 255;
|
||||
--bs-black-rgb: 0, 0, 0;
|
||||
--bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
--bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
--bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));
|
||||
--bs-body-font-family: var(--bs-font-sans-serif);
|
||||
--bs-body-font-size: 1rem;
|
||||
--bs-body-font-weight: 400;
|
||||
--bs-body-line-height: 1.5;
|
||||
--bs-body-color: #212529;
|
||||
--bs-body-color-rgb: 33, 37, 41;
|
||||
--bs-body-bg: #fff;
|
||||
--bs-body-bg-rgb: 255, 255, 255;
|
||||
--bs-emphasis-color: #000;
|
||||
--bs-emphasis-color-rgb: 0, 0, 0;
|
||||
--bs-secondary-color: rgba(33, 37, 41, 0.75);
|
||||
--bs-secondary-color-rgb: 33, 37, 41;
|
||||
--bs-secondary-bg: #e9ecef;
|
||||
--bs-secondary-bg-rgb: 233, 236, 239;
|
||||
--bs-tertiary-color: rgba(33, 37, 41, 0.5);
|
||||
--bs-tertiary-color-rgb: 33, 37, 41;
|
||||
--bs-tertiary-bg: #f8f9fa;
|
||||
--bs-tertiary-bg-rgb: 248, 249, 250;
|
||||
--bs-heading-color: inherit;
|
||||
--bs-link-color: #0d6efd;
|
||||
--bs-link-color-rgb: 13, 110, 253;
|
||||
--bs-link-decoration: underline;
|
||||
--bs-link-hover-color: #0a58ca;
|
||||
--bs-link-hover-color-rgb: 10, 88, 202;
|
||||
--bs-code-color: #d63384;
|
||||
--bs-highlight-color: #212529;
|
||||
--bs-highlight-bg: #fff3cd;
|
||||
--bs-border-width: 1px;
|
||||
--bs-border-style: solid;
|
||||
--bs-border-color: #dee2e6;
|
||||
--bs-border-color-translucent: rgba(0, 0, 0, 0.175);
|
||||
--bs-border-radius: 0.375rem;
|
||||
--bs-border-radius-sm: 0.25rem;
|
||||
--bs-border-radius-lg: 0.5rem;
|
||||
--bs-border-radius-xl: 1rem;
|
||||
--bs-border-radius-xxl: 2rem;
|
||||
--bs-border-radius-2xl: var(--bs-border-radius-xxl);
|
||||
--bs-border-radius-pill: 50rem;
|
||||
--bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||
--bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
||||
--bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175);
|
||||
--bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075);
|
||||
--bs-focus-ring-width: 0.25rem;
|
||||
--bs-focus-ring-opacity: 0.25;
|
||||
--bs-focus-ring-color: rgba(13, 110, 253, 0.25);
|
||||
--bs-form-valid-color: #198754;
|
||||
--bs-form-valid-border-color: #198754;
|
||||
--bs-form-invalid-color: #dc3545;
|
||||
--bs-form-invalid-border-color: #dc3545;
|
||||
}
|
||||
|
||||
[data-bs-theme=dark] {
|
||||
color-scheme: dark;
|
||||
--bs-body-color: #dee2e6;
|
||||
--bs-body-color-rgb: 222, 226, 230;
|
||||
--bs-body-bg: #212529;
|
||||
--bs-body-bg-rgb: 33, 37, 41;
|
||||
--bs-emphasis-color: #fff;
|
||||
--bs-emphasis-color-rgb: 255, 255, 255;
|
||||
--bs-secondary-color: rgba(222, 226, 230, 0.75);
|
||||
--bs-secondary-color-rgb: 222, 226, 230;
|
||||
--bs-secondary-bg: #343a40;
|
||||
--bs-secondary-bg-rgb: 52, 58, 64;
|
||||
--bs-tertiary-color: rgba(222, 226, 230, 0.5);
|
||||
--bs-tertiary-color-rgb: 222, 226, 230;
|
||||
--bs-tertiary-bg: #2b3035;
|
||||
--bs-tertiary-bg-rgb: 43, 48, 53;
|
||||
--bs-primary-text-emphasis: #6ea8fe;
|
||||
--bs-secondary-text-emphasis: #a7acb1;
|
||||
--bs-success-text-emphasis: #75b798;
|
||||
--bs-info-text-emphasis: #6edff6;
|
||||
--bs-warning-text-emphasis: #ffda6a;
|
||||
--bs-danger-text-emphasis: #ea868f;
|
||||
--bs-light-text-emphasis: #f8f9fa;
|
||||
--bs-dark-text-emphasis: #dee2e6;
|
||||
--bs-primary-bg-subtle: #031633;
|
||||
--bs-secondary-bg-subtle: #161719;
|
||||
--bs-success-bg-subtle: #051b11;
|
||||
--bs-info-bg-subtle: #032830;
|
||||
--bs-warning-bg-subtle: #332701;
|
||||
--bs-danger-bg-subtle: #2c0b0e;
|
||||
--bs-light-bg-subtle: #343a40;
|
||||
--bs-dark-bg-subtle: #1a1d20;
|
||||
--bs-primary-border-subtle: #084298;
|
||||
--bs-secondary-border-subtle: #41464b;
|
||||
--bs-success-border-subtle: #0f5132;
|
||||
--bs-info-border-subtle: #087990;
|
||||
--bs-warning-border-subtle: #997404;
|
||||
--bs-danger-border-subtle: #842029;
|
||||
--bs-light-border-subtle: #495057;
|
||||
--bs-dark-border-subtle: #343a40;
|
||||
--bs-heading-color: inherit;
|
||||
--bs-link-color: #6ea8fe;
|
||||
--bs-link-hover-color: #8bb9fe;
|
||||
--bs-link-color-rgb: 110, 168, 254;
|
||||
--bs-link-hover-color-rgb: 139, 185, 254;
|
||||
--bs-code-color: #e685b5;
|
||||
--bs-highlight-color: #dee2e6;
|
||||
--bs-highlight-bg: #664d03;
|
||||
--bs-border-color: #495057;
|
||||
--bs-border-color-translucent: rgba(255, 255, 255, 0.15);
|
||||
--bs-form-valid-color: #75b798;
|
||||
--bs-form-valid-border-color: #75b798;
|
||||
--bs-form-invalid-color: #ea868f;
|
||||
--bs-form-invalid-border-color: #ea868f;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
:root {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--bs-body-font-family);
|
||||
font-size: var(--bs-body-font-size);
|
||||
font-weight: var(--bs-body-font-weight);
|
||||
line-height: var(--bs-body-line-height);
|
||||
color: var(--bs-body-color);
|
||||
text-align: var(--bs-body-text-align);
|
||||
background-color: var(--bs-body-bg);
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
hr {
|
||||
margin: 1rem 0;
|
||||
color: inherit;
|
||||
border: 0;
|
||||
border-top: var(--bs-border-width) solid;
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
h6, h5, h4, h3, h2, h1 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
color: var(--bs-heading-color);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: calc(1.375rem + 1.5vw);
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
h1 {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: calc(1.325rem + 0.9vw);
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
h2 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: calc(1.3rem + 0.6vw);
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
h3 {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: calc(1.275rem + 0.3vw);
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
h4 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
h6 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
abbr[title] {
|
||||
-webkit-text-decoration: underline dotted;
|
||||
text-decoration: underline dotted;
|
||||
cursor: help;
|
||||
-webkit-text-decoration-skip-ink: none;
|
||||
text-decoration-skip-ink: none;
|
||||
}
|
||||
|
||||
address {
|
||||
margin-bottom: 1rem;
|
||||
font-style: normal;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
ol,
|
||||
ul {
|
||||
padding-left: 2rem;
|
||||
}
|
||||
|
||||
ol,
|
||||
ul,
|
||||
dl {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
ol ol,
|
||||
ul ul,
|
||||
ol ul,
|
||||
ul ol {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
dt {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin-bottom: 0.5rem;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
b,
|
||||
strong {
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
small {
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
mark {
|
||||
padding: 0.1875em;
|
||||
color: var(--bs-highlight-color);
|
||||
background-color: var(--bs-highlight-bg);
|
||||
}
|
||||
|
||||
sub,
|
||||
sup {
|
||||
position: relative;
|
||||
font-size: 0.75em;
|
||||
line-height: 0;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
sub {
|
||||
bottom: -0.25em;
|
||||
}
|
||||
|
||||
sup {
|
||||
top: -0.5em;
|
||||
}
|
||||
|
||||
a {
|
||||
color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));
|
||||
text-decoration: underline;
|
||||
}
|
||||
a:hover {
|
||||
--bs-link-color-rgb: var(--bs-link-hover-color-rgb);
|
||||
}
|
||||
|
||||
a:not([href]):not([class]), a:not([href]):not([class]):hover {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
pre,
|
||||
code,
|
||||
kbd,
|
||||
samp {
|
||||
font-family: var(--bs-font-monospace);
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
pre {
|
||||
display: block;
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
overflow: auto;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
pre code {
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 0.875em;
|
||||
color: var(--bs-code-color);
|
||||
word-wrap: break-word;
|
||||
}
|
||||
a > code {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
kbd {
|
||||
padding: 0.1875rem 0.375rem;
|
||||
font-size: 0.875em;
|
||||
color: var(--bs-body-bg);
|
||||
background-color: var(--bs-body-color);
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
kbd kbd {
|
||||
padding: 0;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
figure {
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
img,
|
||||
svg {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
table {
|
||||
caption-side: bottom;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
caption {
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
color: var(--bs-secondary-color);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th {
|
||||
text-align: inherit;
|
||||
text-align: -webkit-match-parent;
|
||||
}
|
||||
|
||||
thead,
|
||||
tbody,
|
||||
tfoot,
|
||||
tr,
|
||||
td,
|
||||
th {
|
||||
border-color: inherit;
|
||||
border-style: solid;
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
label {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
button:focus:not(:focus-visible) {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
input,
|
||||
button,
|
||||
select,
|
||||
optgroup,
|
||||
textarea {
|
||||
margin: 0;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
button,
|
||||
select {
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
[role=button] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
select {
|
||||
word-wrap: normal;
|
||||
}
|
||||
select:disabled {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
button,
|
||||
[type=button],
|
||||
[type=reset],
|
||||
[type=submit] {
|
||||
-webkit-appearance: button;
|
||||
}
|
||||
button:not(:disabled),
|
||||
[type=button]:not(:disabled),
|
||||
[type=reset]:not(:disabled),
|
||||
[type=submit]:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
::-moz-focus-inner {
|
||||
padding: 0;
|
||||
border-style: none;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
fieldset {
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
legend {
|
||||
float: left;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: calc(1.275rem + 0.3vw);
|
||||
line-height: inherit;
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
legend {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
legend + * {
|
||||
clear: left;
|
||||
}
|
||||
|
||||
::-webkit-datetime-edit-fields-wrapper,
|
||||
::-webkit-datetime-edit-text,
|
||||
::-webkit-datetime-edit-minute,
|
||||
::-webkit-datetime-edit-hour-field,
|
||||
::-webkit-datetime-edit-day-field,
|
||||
::-webkit-datetime-edit-month-field,
|
||||
::-webkit-datetime-edit-year-field {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
::-webkit-inner-spin-button {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
[type=search] {
|
||||
-webkit-appearance: textfield;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* rtl:raw:
|
||||
[type="tel"],
|
||||
[type="url"],
|
||||
[type="email"],
|
||||
[type="number"] {
|
||||
direction: ltr;
|
||||
}
|
||||
*/
|
||||
::-webkit-search-decoration {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
::-webkit-color-swatch-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
::-webkit-file-upload-button {
|
||||
font: inherit;
|
||||
-webkit-appearance: button;
|
||||
}
|
||||
|
||||
::file-selector-button {
|
||||
font: inherit;
|
||||
-webkit-appearance: button;
|
||||
}
|
||||
|
||||
output {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
iframe {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
summary {
|
||||
display: list-item;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
progress {
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/*# sourceMappingURL=bootstrap-reboot.css.map */
|
||||
1
PlaylistShared/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css.map
vendored
Normal file
1
PlaylistShared/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css.map
vendored
Normal file
File diff suppressed because one or more lines are too long
6
PlaylistShared/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.min.css
vendored
Normal file
6
PlaylistShared/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
PlaylistShared/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.min.css.map
vendored
Normal file
1
PlaylistShared/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.min.css.map
vendored
Normal file
File diff suppressed because one or more lines are too long
594
PlaylistShared/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.css
vendored
Normal file
594
PlaylistShared/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.css
vendored
Normal file
@@ -0,0 +1,594 @@
|
||||
/*!
|
||||
* Bootstrap Reboot v5.3.3 (https://getbootstrap.com/)
|
||||
* Copyright 2011-2024 The Bootstrap Authors
|
||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
|
||||
*/
|
||||
:root,
|
||||
[data-bs-theme=light] {
|
||||
--bs-blue: #0d6efd;
|
||||
--bs-indigo: #6610f2;
|
||||
--bs-purple: #6f42c1;
|
||||
--bs-pink: #d63384;
|
||||
--bs-red: #dc3545;
|
||||
--bs-orange: #fd7e14;
|
||||
--bs-yellow: #ffc107;
|
||||
--bs-green: #198754;
|
||||
--bs-teal: #20c997;
|
||||
--bs-cyan: #0dcaf0;
|
||||
--bs-black: #000;
|
||||
--bs-white: #fff;
|
||||
--bs-gray: #6c757d;
|
||||
--bs-gray-dark: #343a40;
|
||||
--bs-gray-100: #f8f9fa;
|
||||
--bs-gray-200: #e9ecef;
|
||||
--bs-gray-300: #dee2e6;
|
||||
--bs-gray-400: #ced4da;
|
||||
--bs-gray-500: #adb5bd;
|
||||
--bs-gray-600: #6c757d;
|
||||
--bs-gray-700: #495057;
|
||||
--bs-gray-800: #343a40;
|
||||
--bs-gray-900: #212529;
|
||||
--bs-primary: #0d6efd;
|
||||
--bs-secondary: #6c757d;
|
||||
--bs-success: #198754;
|
||||
--bs-info: #0dcaf0;
|
||||
--bs-warning: #ffc107;
|
||||
--bs-danger: #dc3545;
|
||||
--bs-light: #f8f9fa;
|
||||
--bs-dark: #212529;
|
||||
--bs-primary-rgb: 13, 110, 253;
|
||||
--bs-secondary-rgb: 108, 117, 125;
|
||||
--bs-success-rgb: 25, 135, 84;
|
||||
--bs-info-rgb: 13, 202, 240;
|
||||
--bs-warning-rgb: 255, 193, 7;
|
||||
--bs-danger-rgb: 220, 53, 69;
|
||||
--bs-light-rgb: 248, 249, 250;
|
||||
--bs-dark-rgb: 33, 37, 41;
|
||||
--bs-primary-text-emphasis: #052c65;
|
||||
--bs-secondary-text-emphasis: #2b2f32;
|
||||
--bs-success-text-emphasis: #0a3622;
|
||||
--bs-info-text-emphasis: #055160;
|
||||
--bs-warning-text-emphasis: #664d03;
|
||||
--bs-danger-text-emphasis: #58151c;
|
||||
--bs-light-text-emphasis: #495057;
|
||||
--bs-dark-text-emphasis: #495057;
|
||||
--bs-primary-bg-subtle: #cfe2ff;
|
||||
--bs-secondary-bg-subtle: #e2e3e5;
|
||||
--bs-success-bg-subtle: #d1e7dd;
|
||||
--bs-info-bg-subtle: #cff4fc;
|
||||
--bs-warning-bg-subtle: #fff3cd;
|
||||
--bs-danger-bg-subtle: #f8d7da;
|
||||
--bs-light-bg-subtle: #fcfcfd;
|
||||
--bs-dark-bg-subtle: #ced4da;
|
||||
--bs-primary-border-subtle: #9ec5fe;
|
||||
--bs-secondary-border-subtle: #c4c8cb;
|
||||
--bs-success-border-subtle: #a3cfbb;
|
||||
--bs-info-border-subtle: #9eeaf9;
|
||||
--bs-warning-border-subtle: #ffe69c;
|
||||
--bs-danger-border-subtle: #f1aeb5;
|
||||
--bs-light-border-subtle: #e9ecef;
|
||||
--bs-dark-border-subtle: #adb5bd;
|
||||
--bs-white-rgb: 255, 255, 255;
|
||||
--bs-black-rgb: 0, 0, 0;
|
||||
--bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
--bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
--bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));
|
||||
--bs-body-font-family: var(--bs-font-sans-serif);
|
||||
--bs-body-font-size: 1rem;
|
||||
--bs-body-font-weight: 400;
|
||||
--bs-body-line-height: 1.5;
|
||||
--bs-body-color: #212529;
|
||||
--bs-body-color-rgb: 33, 37, 41;
|
||||
--bs-body-bg: #fff;
|
||||
--bs-body-bg-rgb: 255, 255, 255;
|
||||
--bs-emphasis-color: #000;
|
||||
--bs-emphasis-color-rgb: 0, 0, 0;
|
||||
--bs-secondary-color: rgba(33, 37, 41, 0.75);
|
||||
--bs-secondary-color-rgb: 33, 37, 41;
|
||||
--bs-secondary-bg: #e9ecef;
|
||||
--bs-secondary-bg-rgb: 233, 236, 239;
|
||||
--bs-tertiary-color: rgba(33, 37, 41, 0.5);
|
||||
--bs-tertiary-color-rgb: 33, 37, 41;
|
||||
--bs-tertiary-bg: #f8f9fa;
|
||||
--bs-tertiary-bg-rgb: 248, 249, 250;
|
||||
--bs-heading-color: inherit;
|
||||
--bs-link-color: #0d6efd;
|
||||
--bs-link-color-rgb: 13, 110, 253;
|
||||
--bs-link-decoration: underline;
|
||||
--bs-link-hover-color: #0a58ca;
|
||||
--bs-link-hover-color-rgb: 10, 88, 202;
|
||||
--bs-code-color: #d63384;
|
||||
--bs-highlight-color: #212529;
|
||||
--bs-highlight-bg: #fff3cd;
|
||||
--bs-border-width: 1px;
|
||||
--bs-border-style: solid;
|
||||
--bs-border-color: #dee2e6;
|
||||
--bs-border-color-translucent: rgba(0, 0, 0, 0.175);
|
||||
--bs-border-radius: 0.375rem;
|
||||
--bs-border-radius-sm: 0.25rem;
|
||||
--bs-border-radius-lg: 0.5rem;
|
||||
--bs-border-radius-xl: 1rem;
|
||||
--bs-border-radius-xxl: 2rem;
|
||||
--bs-border-radius-2xl: var(--bs-border-radius-xxl);
|
||||
--bs-border-radius-pill: 50rem;
|
||||
--bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||
--bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
||||
--bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175);
|
||||
--bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075);
|
||||
--bs-focus-ring-width: 0.25rem;
|
||||
--bs-focus-ring-opacity: 0.25;
|
||||
--bs-focus-ring-color: rgba(13, 110, 253, 0.25);
|
||||
--bs-form-valid-color: #198754;
|
||||
--bs-form-valid-border-color: #198754;
|
||||
--bs-form-invalid-color: #dc3545;
|
||||
--bs-form-invalid-border-color: #dc3545;
|
||||
}
|
||||
|
||||
[data-bs-theme=dark] {
|
||||
color-scheme: dark;
|
||||
--bs-body-color: #dee2e6;
|
||||
--bs-body-color-rgb: 222, 226, 230;
|
||||
--bs-body-bg: #212529;
|
||||
--bs-body-bg-rgb: 33, 37, 41;
|
||||
--bs-emphasis-color: #fff;
|
||||
--bs-emphasis-color-rgb: 255, 255, 255;
|
||||
--bs-secondary-color: rgba(222, 226, 230, 0.75);
|
||||
--bs-secondary-color-rgb: 222, 226, 230;
|
||||
--bs-secondary-bg: #343a40;
|
||||
--bs-secondary-bg-rgb: 52, 58, 64;
|
||||
--bs-tertiary-color: rgba(222, 226, 230, 0.5);
|
||||
--bs-tertiary-color-rgb: 222, 226, 230;
|
||||
--bs-tertiary-bg: #2b3035;
|
||||
--bs-tertiary-bg-rgb: 43, 48, 53;
|
||||
--bs-primary-text-emphasis: #6ea8fe;
|
||||
--bs-secondary-text-emphasis: #a7acb1;
|
||||
--bs-success-text-emphasis: #75b798;
|
||||
--bs-info-text-emphasis: #6edff6;
|
||||
--bs-warning-text-emphasis: #ffda6a;
|
||||
--bs-danger-text-emphasis: #ea868f;
|
||||
--bs-light-text-emphasis: #f8f9fa;
|
||||
--bs-dark-text-emphasis: #dee2e6;
|
||||
--bs-primary-bg-subtle: #031633;
|
||||
--bs-secondary-bg-subtle: #161719;
|
||||
--bs-success-bg-subtle: #051b11;
|
||||
--bs-info-bg-subtle: #032830;
|
||||
--bs-warning-bg-subtle: #332701;
|
||||
--bs-danger-bg-subtle: #2c0b0e;
|
||||
--bs-light-bg-subtle: #343a40;
|
||||
--bs-dark-bg-subtle: #1a1d20;
|
||||
--bs-primary-border-subtle: #084298;
|
||||
--bs-secondary-border-subtle: #41464b;
|
||||
--bs-success-border-subtle: #0f5132;
|
||||
--bs-info-border-subtle: #087990;
|
||||
--bs-warning-border-subtle: #997404;
|
||||
--bs-danger-border-subtle: #842029;
|
||||
--bs-light-border-subtle: #495057;
|
||||
--bs-dark-border-subtle: #343a40;
|
||||
--bs-heading-color: inherit;
|
||||
--bs-link-color: #6ea8fe;
|
||||
--bs-link-hover-color: #8bb9fe;
|
||||
--bs-link-color-rgb: 110, 168, 254;
|
||||
--bs-link-hover-color-rgb: 139, 185, 254;
|
||||
--bs-code-color: #e685b5;
|
||||
--bs-highlight-color: #dee2e6;
|
||||
--bs-highlight-bg: #664d03;
|
||||
--bs-border-color: #495057;
|
||||
--bs-border-color-translucent: rgba(255, 255, 255, 0.15);
|
||||
--bs-form-valid-color: #75b798;
|
||||
--bs-form-valid-border-color: #75b798;
|
||||
--bs-form-invalid-color: #ea868f;
|
||||
--bs-form-invalid-border-color: #ea868f;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
:root {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--bs-body-font-family);
|
||||
font-size: var(--bs-body-font-size);
|
||||
font-weight: var(--bs-body-font-weight);
|
||||
line-height: var(--bs-body-line-height);
|
||||
color: var(--bs-body-color);
|
||||
text-align: var(--bs-body-text-align);
|
||||
background-color: var(--bs-body-bg);
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
hr {
|
||||
margin: 1rem 0;
|
||||
color: inherit;
|
||||
border: 0;
|
||||
border-top: var(--bs-border-width) solid;
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
h6, h5, h4, h3, h2, h1 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
color: var(--bs-heading-color);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: calc(1.375rem + 1.5vw);
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
h1 {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: calc(1.325rem + 0.9vw);
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
h2 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: calc(1.3rem + 0.6vw);
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
h3 {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: calc(1.275rem + 0.3vw);
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
h4 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
h6 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
abbr[title] {
|
||||
-webkit-text-decoration: underline dotted;
|
||||
text-decoration: underline dotted;
|
||||
cursor: help;
|
||||
-webkit-text-decoration-skip-ink: none;
|
||||
text-decoration-skip-ink: none;
|
||||
}
|
||||
|
||||
address {
|
||||
margin-bottom: 1rem;
|
||||
font-style: normal;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
ol,
|
||||
ul {
|
||||
padding-right: 2rem;
|
||||
}
|
||||
|
||||
ol,
|
||||
ul,
|
||||
dl {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
ol ol,
|
||||
ul ul,
|
||||
ol ul,
|
||||
ul ol {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
dt {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin-bottom: 0.5rem;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
b,
|
||||
strong {
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
small {
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
mark {
|
||||
padding: 0.1875em;
|
||||
color: var(--bs-highlight-color);
|
||||
background-color: var(--bs-highlight-bg);
|
||||
}
|
||||
|
||||
sub,
|
||||
sup {
|
||||
position: relative;
|
||||
font-size: 0.75em;
|
||||
line-height: 0;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
sub {
|
||||
bottom: -0.25em;
|
||||
}
|
||||
|
||||
sup {
|
||||
top: -0.5em;
|
||||
}
|
||||
|
||||
a {
|
||||
color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));
|
||||
text-decoration: underline;
|
||||
}
|
||||
a:hover {
|
||||
--bs-link-color-rgb: var(--bs-link-hover-color-rgb);
|
||||
}
|
||||
|
||||
a:not([href]):not([class]), a:not([href]):not([class]):hover {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
pre,
|
||||
code,
|
||||
kbd,
|
||||
samp {
|
||||
font-family: var(--bs-font-monospace);
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
pre {
|
||||
display: block;
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
overflow: auto;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
pre code {
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 0.875em;
|
||||
color: var(--bs-code-color);
|
||||
word-wrap: break-word;
|
||||
}
|
||||
a > code {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
kbd {
|
||||
padding: 0.1875rem 0.375rem;
|
||||
font-size: 0.875em;
|
||||
color: var(--bs-body-bg);
|
||||
background-color: var(--bs-body-color);
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
kbd kbd {
|
||||
padding: 0;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
figure {
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
img,
|
||||
svg {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
table {
|
||||
caption-side: bottom;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
caption {
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
color: var(--bs-secondary-color);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
th {
|
||||
text-align: inherit;
|
||||
text-align: -webkit-match-parent;
|
||||
}
|
||||
|
||||
thead,
|
||||
tbody,
|
||||
tfoot,
|
||||
tr,
|
||||
td,
|
||||
th {
|
||||
border-color: inherit;
|
||||
border-style: solid;
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
label {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
button:focus:not(:focus-visible) {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
input,
|
||||
button,
|
||||
select,
|
||||
optgroup,
|
||||
textarea {
|
||||
margin: 0;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
button,
|
||||
select {
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
[role=button] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
select {
|
||||
word-wrap: normal;
|
||||
}
|
||||
select:disabled {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
button,
|
||||
[type=button],
|
||||
[type=reset],
|
||||
[type=submit] {
|
||||
-webkit-appearance: button;
|
||||
}
|
||||
button:not(:disabled),
|
||||
[type=button]:not(:disabled),
|
||||
[type=reset]:not(:disabled),
|
||||
[type=submit]:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
::-moz-focus-inner {
|
||||
padding: 0;
|
||||
border-style: none;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
fieldset {
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
legend {
|
||||
float: right;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: calc(1.275rem + 0.3vw);
|
||||
line-height: inherit;
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
legend {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
legend + * {
|
||||
clear: right;
|
||||
}
|
||||
|
||||
::-webkit-datetime-edit-fields-wrapper,
|
||||
::-webkit-datetime-edit-text,
|
||||
::-webkit-datetime-edit-minute,
|
||||
::-webkit-datetime-edit-hour-field,
|
||||
::-webkit-datetime-edit-day-field,
|
||||
::-webkit-datetime-edit-month-field,
|
||||
::-webkit-datetime-edit-year-field {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
::-webkit-inner-spin-button {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
[type=search] {
|
||||
-webkit-appearance: textfield;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
[type="tel"],
|
||||
[type="url"],
|
||||
[type="email"],
|
||||
[type="number"] {
|
||||
direction: ltr;
|
||||
}
|
||||
::-webkit-search-decoration {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
::-webkit-color-swatch-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
::-webkit-file-upload-button {
|
||||
font: inherit;
|
||||
-webkit-appearance: button;
|
||||
}
|
||||
|
||||
::file-selector-button {
|
||||
font: inherit;
|
||||
-webkit-appearance: button;
|
||||
}
|
||||
|
||||
output {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
iframe {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
summary {
|
||||
display: list-item;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
progress {
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
/*# sourceMappingURL=bootstrap-reboot.rtl.css.map */
|
||||
1
PlaylistShared/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.css.map
vendored
Normal file
1
PlaylistShared/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.css.map
vendored
Normal file
File diff suppressed because one or more lines are too long
6
PlaylistShared/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.min.css
vendored
Normal file
6
PlaylistShared/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
PlaylistShared/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.min.css.map
vendored
Normal file
1
PlaylistShared/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.min.css.map
vendored
Normal file
File diff suppressed because one or more lines are too long
5402
PlaylistShared/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.css
vendored
Normal file
5402
PlaylistShared/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.css
vendored
Normal file
File diff suppressed because it is too large
Load Diff
1
PlaylistShared/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.css.map
vendored
Normal file
1
PlaylistShared/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.css.map
vendored
Normal file
File diff suppressed because one or more lines are too long
6
PlaylistShared/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.min.css
vendored
Normal file
6
PlaylistShared/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
PlaylistShared/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.min.css.map
vendored
Normal file
1
PlaylistShared/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.min.css.map
vendored
Normal file
File diff suppressed because one or more lines are too long
5393
PlaylistShared/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.css
vendored
Normal file
5393
PlaylistShared/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.css
vendored
Normal file
File diff suppressed because it is too large
Load Diff
1
PlaylistShared/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.css.map
vendored
Normal file
1
PlaylistShared/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.css.map
vendored
Normal file
File diff suppressed because one or more lines are too long
6
PlaylistShared/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.min.css
vendored
Normal file
6
PlaylistShared/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
PlaylistShared/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.min.css.map
vendored
Normal file
1
PlaylistShared/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.min.css.map
vendored
Normal file
File diff suppressed because one or more lines are too long
12057
PlaylistShared/wwwroot/lib/bootstrap/dist/css/bootstrap.css
vendored
Normal file
12057
PlaylistShared/wwwroot/lib/bootstrap/dist/css/bootstrap.css
vendored
Normal file
File diff suppressed because it is too large
Load Diff
1
PlaylistShared/wwwroot/lib/bootstrap/dist/css/bootstrap.css.map
vendored
Normal file
1
PlaylistShared/wwwroot/lib/bootstrap/dist/css/bootstrap.css.map
vendored
Normal file
File diff suppressed because one or more lines are too long
6
PlaylistShared/wwwroot/lib/bootstrap/dist/css/bootstrap.min.css
vendored
Normal file
6
PlaylistShared/wwwroot/lib/bootstrap/dist/css/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
PlaylistShared/wwwroot/lib/bootstrap/dist/css/bootstrap.min.css.map
vendored
Normal file
1
PlaylistShared/wwwroot/lib/bootstrap/dist/css/bootstrap.min.css.map
vendored
Normal file
File diff suppressed because one or more lines are too long
12030
PlaylistShared/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.css
vendored
Normal file
12030
PlaylistShared/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.css
vendored
Normal file
File diff suppressed because it is too large
Load Diff
1
PlaylistShared/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.css.map
vendored
Normal file
1
PlaylistShared/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.css.map
vendored
Normal file
File diff suppressed because one or more lines are too long
6
PlaylistShared/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.min.css
vendored
Normal file
6
PlaylistShared/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
PlaylistShared/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.min.css.map
vendored
Normal file
1
PlaylistShared/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.min.css.map
vendored
Normal file
File diff suppressed because one or more lines are too long
6314
PlaylistShared/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.js
vendored
Normal file
6314
PlaylistShared/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
1
PlaylistShared/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.js.map
vendored
Normal file
1
PlaylistShared/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
7
PlaylistShared/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.min.js
vendored
Normal file
7
PlaylistShared/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
PlaylistShared/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.min.js.map
vendored
Normal file
1
PlaylistShared/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.min.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
4447
PlaylistShared/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.js
vendored
Normal file
4447
PlaylistShared/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
1
PlaylistShared/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.js.map
vendored
Normal file
1
PlaylistShared/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
7
PlaylistShared/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.min.js
vendored
Normal file
7
PlaylistShared/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
PlaylistShared/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.min.js.map
vendored
Normal file
1
PlaylistShared/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.min.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
4494
PlaylistShared/wwwroot/lib/bootstrap/dist/js/bootstrap.js
vendored
Normal file
4494
PlaylistShared/wwwroot/lib/bootstrap/dist/js/bootstrap.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
1
PlaylistShared/wwwroot/lib/bootstrap/dist/js/bootstrap.js.map
vendored
Normal file
1
PlaylistShared/wwwroot/lib/bootstrap/dist/js/bootstrap.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
7
PlaylistShared/wwwroot/lib/bootstrap/dist/js/bootstrap.min.js
vendored
Normal file
7
PlaylistShared/wwwroot/lib/bootstrap/dist/js/bootstrap.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
PlaylistShared/wwwroot/lib/bootstrap/dist/js/bootstrap.min.js.map
vendored
Normal file
1
PlaylistShared/wwwroot/lib/bootstrap/dist/js/bootstrap.min.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -57,6 +57,16 @@ public class AuthStorage
|
||||
Provider = provider;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Конструктор
|
||||
/// </summary>
|
||||
public AuthStorage()
|
||||
{
|
||||
User = new YAccount();
|
||||
Context = new HttpContext();
|
||||
Provider = new DefaultRequestProvider(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Установка прокси для пользователия
|
||||
/// </summary>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
<Solution>
|
||||
<Project Path="PlaylistShared/PlaylistShared.csproj" Id="b0fd4b2b-53cf-4087-a83a-703e803f64c6" />
|
||||
<Project Path="YandexMusic.API/YandexMusic.API.csproj" />
|
||||
<Project Path="YandexMusic/YandexMusic.csproj" Id="044fcef4-86d2-4cc9-9f7e-a577c19ae5c3" />
|
||||
</Solution>
|
||||
|
||||
13
YandexMusic/YandexMusic.csproj
Normal file
13
YandexMusic/YandexMusic.csproj
Normal file
@@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\YandexMusic.API\YandexMusic.API.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
517
YandexMusic/YandexMusicClient.cs
Normal file
517
YandexMusic/YandexMusicClient.cs
Normal file
@@ -0,0 +1,517 @@
|
||||
using YandexMusic.API;
|
||||
using YandexMusic.API.Common;
|
||||
using YandexMusic.API.Common.Ynison;
|
||||
using YandexMusic.API.Models.Account;
|
||||
using YandexMusic.API.Models.Album;
|
||||
using YandexMusic.API.Models.Artist;
|
||||
using YandexMusic.API.Models.Common;
|
||||
using YandexMusic.API.Models.Feed;
|
||||
using YandexMusic.API.Models.Landing;
|
||||
using YandexMusic.API.Models.Landing.Entity.Entities.Context;
|
||||
using YandexMusic.API.Models.Library;
|
||||
using YandexMusic.API.Models.Playlist;
|
||||
using YandexMusic.API.Models.Queue;
|
||||
using YandexMusic.API.Models.Radio;
|
||||
using YandexMusic.API.Models.Search;
|
||||
using YandexMusic.API.Models.Track;
|
||||
|
||||
namespace YandexMusic;
|
||||
|
||||
/// <summary>Асинхронный клиент для работы с API Яндекс Музыки.</summary>
|
||||
public class YandexMusicClient : IDisposable
|
||||
{
|
||||
private readonly YandexMusicApi _api;
|
||||
private readonly AuthStorage _storage;
|
||||
private YnisonPlayer? _player;
|
||||
|
||||
/// <summary>Информация об аккаунте (после авторизации).</summary>
|
||||
public YAccount Account => _storage.User;
|
||||
|
||||
/// <summary>Флаг авторизации.</summary>
|
||||
public bool IsAuthorized => _storage.IsAuthorized;
|
||||
|
||||
/// <summary>Плеер Ynison (WebSocket) для синхронизации воспроизведения.</summary>
|
||||
public YnisonPlayer? Ynison => _player;
|
||||
|
||||
/// <summary>Создаёт новый экземпляр клиента Яндекс Музыки.</summary>
|
||||
public YandexMusicClient()
|
||||
{
|
||||
_api = new YandexMusicApi();
|
||||
_storage = new AuthStorage();
|
||||
_player = _api.Ynison.GetPlayer(_storage);
|
||||
}
|
||||
|
||||
#region Авторизация
|
||||
|
||||
/// <summary>
|
||||
/// Авторизация по токену
|
||||
/// </summary>
|
||||
/// <param name="token">Токен авторизации</param>
|
||||
/// <returns></returns>
|
||||
public async Task<bool> Authorize(string token)
|
||||
{
|
||||
await _api.User.AuthorizeAsync(_storage, token);
|
||||
return _storage.IsAuthorized;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Создание сеанса и получение доступных методов авторизации
|
||||
/// </summary>
|
||||
/// <param name="userName">Имя пользователя</param>
|
||||
/// <returns></returns>
|
||||
public Task<YAuthTypes> CreateAuthSession(string userName)
|
||||
{
|
||||
return _api.User.CreateAuthSessionAsync(_storage, userName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Получение ссылки на QR-код
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public Task<string> GetAuthQRLink()
|
||||
{
|
||||
return _api.User.GetAuthQRLinkAsync(_storage);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Авторизация по QR-коду
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public Task<YAuthQRStatus> AuthorizeByQR()
|
||||
{
|
||||
return _api.User.AuthorizeByQRAsync(_storage);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Получение <see cref="YAuthCaptcha"/>
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public Task<YAuthCaptcha> GetCaptcha()
|
||||
{
|
||||
return _api.User.GetCaptchaAsync(_storage);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Авторизация по captcha
|
||||
/// </summary>
|
||||
/// <param name="captcha">Значение captcha</param>
|
||||
/// <returns></returns>
|
||||
public Task<YAuthBase> AuthorizeByCaptcha(string captcha)
|
||||
{
|
||||
return _api.User.AuthorizeByCaptchaAsync(_storage, captcha);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Получение письма авторизации на почту пользователя
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public Task<YAuthLetter> GetAuthLetter()
|
||||
{
|
||||
return _api.User.GetAuthLetterAsync(_storage);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Авторизация после подтверждения входа через письмо
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public Task<bool> AuthorizeByLetter()
|
||||
{
|
||||
return _api.User.AuthorizeByLetterAsync(_storage);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Авторизация с помощью пароля из приложения Яндекс
|
||||
/// </summary>
|
||||
/// <param name="password">Пароль</param>
|
||||
/// <returns></returns>
|
||||
public Task<YAuthBase> AuthorizeByAppPassword(string password)
|
||||
{
|
||||
return _api.User.AuthorizeByAppPasswordAsync(_storage, password);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Получение <see cref="YAccessToken"/> после авторизации с помощью QR, e-mail, пароля из приложения
|
||||
/// </summary>
|
||||
public Task<YAccessToken> GetAccessToken()
|
||||
{
|
||||
return _api.User.GetAccessTokenAsync(_storage);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Получение информации о пользователе через логин Яндекса
|
||||
/// </summary>
|
||||
public Task<YLoginInfo> GetLoginInfo()
|
||||
{
|
||||
return _api.User.GetLoginInfoAsync(_storage);
|
||||
}
|
||||
|
||||
#endregion Авторизация
|
||||
|
||||
#region Треки
|
||||
/// <summary>Получает трек по идентификатору.</summary>
|
||||
public async Task<YTrack?> GetTrackAsync(string id)
|
||||
{
|
||||
var response = await _api.Track.GetAsync(_storage, id);
|
||||
return response?.Result?.FirstOrDefault();
|
||||
}
|
||||
|
||||
/// <summary>Получает список треков по идентификаторам.</summary>
|
||||
public async Task<List<YTrack>> GetTracksAsync(IEnumerable<string> ids)
|
||||
{
|
||||
var response = await _api.Track.GetAsync(_storage, ids);
|
||||
return response?.Result ?? new List<YTrack>();
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Альбомы
|
||||
/// <summary>Получает альбом по идентификатору.</summary>
|
||||
public async Task<YAlbum?> GetAlbumAsync(string id)
|
||||
{
|
||||
var response = await _api.Album.GetAsync(_storage, id);
|
||||
return response?.Result;
|
||||
}
|
||||
|
||||
/// <summary>Получает список альбомов по идентификаторам.</summary>
|
||||
public async Task<List<YAlbum>> GetAlbumsAsync(IEnumerable<string> ids)
|
||||
{
|
||||
var response = await _api.Album.GetAsync(_storage, ids);
|
||||
return response?.Result ?? new List<YAlbum>();
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Главная страница
|
||||
/// <summary>Получает персональные блоки лендинга.</summary>
|
||||
public async Task<YLanding?> GetLandingAsync(params YLandingBlockType[] blocks)
|
||||
{
|
||||
var response = await _api.Landing.GetAsync(_storage, blocks);
|
||||
return response?.Result;
|
||||
}
|
||||
|
||||
/// <summary>Получает ленту событий.</summary>
|
||||
public async Task<YFeed?> GetFeedAsync()
|
||||
{
|
||||
var response = await _api.Landing.GetFeedAsync(_storage);
|
||||
return response?.Result;
|
||||
}
|
||||
|
||||
/// <summary>Получает детский лендинг.</summary>
|
||||
public async Task<YChildrenLanding?> GetChildrenLandingAsync()
|
||||
{
|
||||
var response = await _api.Landing.GetChildrenLandingAsync(_storage);
|
||||
return response?.Result;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Исполнители
|
||||
/// <summary>Получает информацию об исполнителе.</summary>
|
||||
public async Task<YArtistBriefInfo?> GetArtistAsync(string id)
|
||||
{
|
||||
var response = await _api.Artist.GetAsync(_storage, id);
|
||||
return response?.Result;
|
||||
}
|
||||
|
||||
/// <summary>Получает список исполнителей.</summary>
|
||||
public async Task<List<YArtist>> GetArtistsAsync(IEnumerable<string> ids)
|
||||
{
|
||||
var response = await _api.Artist.GetAsync(_storage, ids);
|
||||
return response?.Result ?? new List<YArtist>();
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Плейлисты
|
||||
/// <summary>Получает плейлист по пользователю и идентификатору.</summary>
|
||||
public async Task<YPlaylist?> GetPlaylistAsync(string user, string id)
|
||||
{
|
||||
var response = await _api.Playlist.GetAsync(_storage, user, id);
|
||||
return response?.Result;
|
||||
}
|
||||
|
||||
/// <summary>Получает плейлист по UUID.</summary>
|
||||
public async Task<YPlaylist?> GetPlaylistAsync(string uuid)
|
||||
{
|
||||
var response = await _api.Playlist.GetAsync(_storage, uuid);
|
||||
return response?.Result;
|
||||
}
|
||||
|
||||
/// <summary>Получает список плейлистов по списку пар (пользователь, идентификатор).</summary>
|
||||
public async Task<List<YPlaylist>> GetPlaylistsAsync(IEnumerable<(string user, string id)> ids)
|
||||
{
|
||||
var response = await _api.Playlist.GetAsync(_storage, ids);
|
||||
return response?.Result ?? new List<YPlaylist>();
|
||||
}
|
||||
|
||||
/// <summary>Получает персональные плейлисты (с главной страницы).</summary>
|
||||
public async Task<List<YPlaylist>> GetPersonalPlaylistsAsync()
|
||||
{
|
||||
var playlists = await _api.Playlist.GetPersonalPlaylistsAsync(_storage);
|
||||
return playlists.Select(r => r.Result).Where(p => p != null).ToList()!;
|
||||
}
|
||||
|
||||
/// <summary>Получает избранные плейлисты.</summary>
|
||||
public async Task<List<YPlaylist>> GetFavoritesAsync()
|
||||
{
|
||||
var response = await _api.Playlist.FavoritesAsync(_storage);
|
||||
return response?.Result ?? new List<YPlaylist>();
|
||||
}
|
||||
|
||||
/// <summary>Получает плейлист «Дежавю».</summary>
|
||||
public async Task<YPlaylist?> GetDejaVuAsync()
|
||||
{
|
||||
var response = await _api.Playlist.DejaVuAsync(_storage);
|
||||
return response?.Result;
|
||||
}
|
||||
|
||||
/// <summary>Получает плейлист «Тайник».</summary>
|
||||
public async Task<YPlaylist?> GetMissedAsync()
|
||||
{
|
||||
var response = await _api.Playlist.MissedAsync(_storage);
|
||||
return response?.Result;
|
||||
}
|
||||
|
||||
/// <summary>Получает плейлист дня.</summary>
|
||||
public async Task<YPlaylist?> GetOfTheDayAsync()
|
||||
{
|
||||
var response = await _api.Playlist.OfTheDayAsync(_storage);
|
||||
return response?.Result;
|
||||
}
|
||||
|
||||
/// <summary>Получает плейлист «Кинопоиск».</summary>
|
||||
public async Task<YPlaylist?> GetKinopoiskAsync()
|
||||
{
|
||||
var response = await _api.Playlist.KinopoiskAsync(_storage);
|
||||
return response?.Result;
|
||||
}
|
||||
|
||||
/// <summary>Получает плейлист «Премьера».</summary>
|
||||
public async Task<YPlaylist?> GetPremiereAsync()
|
||||
{
|
||||
var response = await _api.Playlist.PremiereAsync(_storage);
|
||||
return response?.Result;
|
||||
}
|
||||
|
||||
/// <summary>Создаёт новый плейлист с заданным именем.</summary>
|
||||
public async Task<YPlaylist?> CreatePlaylistAsync(string name)
|
||||
{
|
||||
var response = await _api.Playlist.CreateAsync(_storage, name);
|
||||
return response?.Result;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Поиск
|
||||
/// <summary>Выполняет поиск.</summary>
|
||||
public async Task<YSearch?> SearchAsync(string searchText, YSearchType searchType, int page = 0, int pageSize = 20)
|
||||
{
|
||||
var response = await _api.Search.SearchAsync(_storage, searchText, searchType, page, pageSize);
|
||||
return response?.Result;
|
||||
}
|
||||
|
||||
/// <summary>Получает подсказки для поискового запроса.</summary>
|
||||
public async Task<YSearchSuggest?> GetSearchSuggestionsAsync(string searchText)
|
||||
{
|
||||
var response = await _api.Search.SuggestAsync(_storage, searchText);
|
||||
return response?.Result;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Библиотека
|
||||
/// <summary>Получает лайкнутые треки.</summary>
|
||||
public async Task<List<YTrack>> GetLikedTracksAsync()
|
||||
{
|
||||
var likes = await _api.Library.GetLikedTracksAsync(_storage);
|
||||
var ids = likes.Result?.Library.Tracks.Select(t => t.Id).ToArray() ?? Array.Empty<string>();
|
||||
if (ids.Length == 0) return new List<YTrack>();
|
||||
var response = await _api.Track.GetAsync(_storage, ids);
|
||||
return response?.Result ?? new List<YTrack>();
|
||||
}
|
||||
|
||||
/// <summary>Получает дизлайкнутые треки.</summary>
|
||||
public async Task<List<YTrack>> GetDislikedTracksAsync()
|
||||
{
|
||||
var dislikes = await _api.Library.GetDislikedTracksAsync(_storage);
|
||||
var ids = dislikes.Result?.Library.Tracks.Select(t => t.Id).ToArray() ?? Array.Empty<string>();
|
||||
if (ids.Length == 0) return new List<YTrack>();
|
||||
var response = await _api.Track.GetAsync(_storage, ids);
|
||||
return response?.Result ?? new List<YTrack>();
|
||||
}
|
||||
|
||||
/// <summary>Получает лайкнутые альбомы.</summary>
|
||||
public async Task<List<YAlbum>> GetLikedAlbumsAsync()
|
||||
{
|
||||
var albums = await _api.Library.GetLikedAlbumsAsync(_storage);
|
||||
var ids = albums.Result?.Select(a => a.Id).ToArray() ?? Array.Empty<string>();
|
||||
if (ids.Length == 0) return new List<YAlbum>();
|
||||
var response = await _api.Album.GetAsync(_storage, ids);
|
||||
return response?.Result ?? new List<YAlbum>();
|
||||
}
|
||||
|
||||
/// <summary>Получает лайкнутых исполнителей.</summary>
|
||||
public async Task<List<YArtist>> GetLikedArtistsAsync()
|
||||
{
|
||||
var artists = await _api.Library.GetLikedArtistsAsync(_storage);
|
||||
var ids = artists.Result?.Select(a => a.Id).ToArray() ?? Array.Empty<string>();
|
||||
if (ids.Length == 0) return new List<YArtist>();
|
||||
var response = await _api.Artist.GetAsync(_storage, ids);
|
||||
return response?.Result ?? new List<YArtist>();
|
||||
}
|
||||
|
||||
/// <summary>Получает дизлайкнутых исполнителей.</summary>
|
||||
public async Task<List<YArtist>> GetDislikedArtistsAsync()
|
||||
{
|
||||
var artists = await _api.Library.GetDislikedArtistsAsync(_storage);
|
||||
var ids = artists.Result?.Select(a => a.Id).ToArray() ?? Array.Empty<string>();
|
||||
if (ids.Length == 0) return new List<YArtist>();
|
||||
var response = await _api.Artist.GetAsync(_storage, ids);
|
||||
return response?.Result ?? new List<YArtist>();
|
||||
}
|
||||
|
||||
/// <summary>Получает лайкнутые плейлисты.</summary>
|
||||
public async Task<List<YPlaylist>> GetLikedPlaylistsAsync()
|
||||
{
|
||||
var playlists = await _api.Library.GetLikedPlaylistsAsync(_storage);
|
||||
var ids = playlists.Result?
|
||||
.Select(p => (p.Playlist.Uid, p.Playlist.Kind))
|
||||
.ToArray() ?? Array.Empty<(string, string)>();
|
||||
if (ids.Length == 0) return new List<YPlaylist>();
|
||||
var response = await _api.Playlist.GetAsync(_storage, ids);
|
||||
return response?.Result ?? new List<YPlaylist>();
|
||||
}
|
||||
|
||||
/// <summary>Получает список недавно прослушанных треков.</summary>
|
||||
/// <param name="contextTypes">Типы контекстов (альбом, исполнитель, плейлист).</param>
|
||||
/// <param name="trackCount">Количество треков на контекст.</param>
|
||||
/// <param name="contextCount">Количество контекстов.</param>
|
||||
public async Task<List<YRecentlyListened>> GetRecentlyListenedAsync(
|
||||
IEnumerable<YPlayContextType> contextTypes,
|
||||
int trackCount = 50,
|
||||
int contextCount = 10)
|
||||
{
|
||||
var response = await _api.Library.GetRecentlyListenedAsync(_storage, contextTypes, trackCount, contextCount);
|
||||
return response?.Result?.Contexts ?? new List<YRecentlyListened>();
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Радио
|
||||
/// <summary>Получает список рекомендованных радиостанций.</summary>
|
||||
public async Task<List<YStation>> GetRadioDashboardAsync()
|
||||
{
|
||||
var response = await _api.Radio.GetStationsDashboardAsync(_storage);
|
||||
return response?.Result?.Stations ?? new List<YStation>();
|
||||
}
|
||||
|
||||
/// <summary>Получает список всех радиостанций.</summary>
|
||||
public async Task<List<YStation>> GetRadioStationsAsync()
|
||||
{
|
||||
var response = await _api.Radio.GetStationsAsync(_storage);
|
||||
return response?.Result ?? new List<YStation>();
|
||||
}
|
||||
|
||||
/// <summary>Получает информацию о радиостанции по идентификатору.</summary>
|
||||
public async Task<YStation?> GetRadioStationAsync(YStationId id)
|
||||
{
|
||||
var response = await _api.Radio.GetStationAsync(_storage, id);
|
||||
return response?.Result?.FirstOrDefault();
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Очереди
|
||||
/// <summary>Получает все очереди с разных устройств.</summary>
|
||||
public async Task<YQueueItemsContainer?> GetQueuesAsync(string? device = null)
|
||||
{
|
||||
var response = await _api.Queue.ListAsync(_storage, device);
|
||||
return response?.Result;
|
||||
}
|
||||
|
||||
/// <summary>Получает очередь по идентификатору.</summary>
|
||||
public async Task<YQueue?> GetQueueAsync(string queueId)
|
||||
{
|
||||
var response = await _api.Queue.GetAsync(_storage, queueId);
|
||||
return response?.Result;
|
||||
}
|
||||
|
||||
/// <summary>Создаёт новую очередь.</summary>
|
||||
public async Task<YNewQueue?> CreateQueueAsync(YQueue queue, string? device = null)
|
||||
{
|
||||
var response = await _api.Queue.CreateAsync(_storage, queue, device);
|
||||
return response?.Result;
|
||||
}
|
||||
|
||||
/// <summary>Обновляет позицию в очереди.</summary>
|
||||
public async Task<YUpdatedQueue?> UpdateQueuePositionAsync(string queueId, int currentIndex, bool isInteractive, string? device = null)
|
||||
{
|
||||
var response = await _api.Queue.UpdatePositionAsync(_storage, queueId, currentIndex, isInteractive, device);
|
||||
return response?.Result;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Загрузка треков (UGC)
|
||||
/// <summary>Загружает трек из файла в плейлист.</summary>
|
||||
public async Task<string?> UploadTrackToPlaylistAsync(YPlaylist playlist, string fileName, string filePath)
|
||||
{
|
||||
var uploadLink = await _api.UserGeneratedContent.GetUgcUploadLinkAsync(_storage, playlist, fileName);
|
||||
var response = await _api.UserGeneratedContent.UploadUgcTrackAsync(_storage, uploadLink.PostTarget, filePath);
|
||||
return response?.Result;
|
||||
}
|
||||
|
||||
/// <summary>Загружает трек из потока в плейлист.</summary>
|
||||
public async Task<string?> UploadTrackToPlaylistAsync(YPlaylist playlist, string fileName, Stream stream)
|
||||
{
|
||||
var uploadLink = await _api.UserGeneratedContent.GetUgcUploadLinkAsync(_storage, playlist, fileName);
|
||||
var response = await _api.UserGeneratedContent.UploadUgcTrackAsync(_storage, uploadLink.PostTarget, stream);
|
||||
return response?.Result;
|
||||
}
|
||||
|
||||
/// <summary>Загружает трек из массива байтов в плейлист.</summary>
|
||||
public async Task<string?> UploadTrackToPlaylistAsync(YPlaylist playlist, string fileName, byte[] file)
|
||||
{
|
||||
var uploadLink = await _api.UserGeneratedContent.GetUgcUploadLinkAsync(_storage, playlist, fileName);
|
||||
var response = await _api.UserGeneratedContent.UploadUgcTrackAsync(_storage, uploadLink.PostTarget, file);
|
||||
return response?.Result;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Лейблы
|
||||
/// <summary>Получает альбомы лейбла с пагинацией.</summary>
|
||||
public async Task<List<YAlbum>> GetAlbumsByLabelAsync(YLabel label, int page = 0)
|
||||
{
|
||||
var response = await _api.Label.GetAlbumsByLabelAsync(_storage, label, page);
|
||||
return response?.Result?.Albums ?? new List<YAlbum>();
|
||||
}
|
||||
|
||||
/// <summary>Получает артистов лейбла с пагинацией.</summary>
|
||||
public async Task<List<YArtist>> GetArtistsByLabelAsync(YLabel label, int page = 0)
|
||||
{
|
||||
var response = await _api.Label.GetArtistsByLabelAsync(_storage, label, page);
|
||||
return response?.Result?.Artists ?? new List<YArtist>();
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Ynison (WebSocket плеер)
|
||||
/// <summary>Подключается к Ynison и запускает синхронизацию состояния плеера.</summary>
|
||||
public async Task ConnectYnisonAsync()
|
||||
{
|
||||
if (_player == null)
|
||||
_player = _api.Ynison.GetPlayer(_storage);
|
||||
await _player.ConnectAsync();
|
||||
}
|
||||
|
||||
/// <summary>Отключается от Ynison.</summary>
|
||||
public async Task DisconnectYnisonAsync()
|
||||
{
|
||||
if (_player != null)
|
||||
await _player.DisconnectAsync();
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region IDisposable
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>Освобождает ресурсы.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_player?.Dispose();
|
||||
_disposed = true;
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
Reference in New Issue
Block a user