Вынесен глобальный плеер

This commit is contained in:
FrigaT
2026-04-14 18:59:00 +03:00
parent 65efb9ff76
commit e0fca7e55e
10 changed files with 379 additions and 191 deletions

View File

@@ -1,4 +1,5 @@
@using Microsoft.AspNetCore.Components.Web @using Microsoft.AspNetCore.Components.Web
@inject IAudioPlayerService AudioPlayerService
<div class="track-cover-container" <div class="track-cover-container"
@onmouseenter="HandleMouseEnter" @onmouseenter="HandleMouseEnter"
@@ -7,7 +8,7 @@
<MudImage Src="@FormatCoverUrl(CoverUrl)" Height="@Height" Width="@Width" Class="rounded" Style="display: block;" /> <MudImage Src="@FormatCoverUrl(CoverUrl)" Height="@Height" Width="@Width" Class="rounded" Style="display: block;" />
@if (_isHovered || IsPlaying) @if (_isHovered || IsCurrentTrackPlaying)
{ {
<div class="play-overlay" <div class="play-overlay"
style="position: absolute; top: 0; left: 0; right: 0; bottom: 0; style="position: absolute; top: 0; left: 0; right: 0; bottom: 0;
@@ -16,7 +17,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border-radius: 4px;"> border-radius: 4px;">
<MudIconButton Icon="@(IsPlaying? Icons.Material.Filled.Pause : Icons.Material.Filled.PlayArrow)" <MudIconButton Icon="@(IsCurrentTrackPlaying? Icons.Material.Filled.Pause : Icons.Material.Filled.PlayArrow)"
Color="Color.Inherit" Color="Color.Inherit"
Size="Size.Large" Size="Size.Large"
OnClick="OnPlayClick" /> OnClick="OnPlayClick" />
@@ -27,19 +28,39 @@
@code { @code {
[Parameter] public string CoverUrl { get; set; } = string.Empty; [Parameter] public string CoverUrl { get; set; } = string.Empty;
[Parameter] public string TrackId { get; set; } = string.Empty; [Parameter] public string TrackId { get; set; } = string.Empty;
[Parameter] public bool IsPlaying { get; set; } = false;
[Parameter] public EventCallback<string> OnPlay { get; set; }
[Parameter] public int Height { get; set; } = 50; [Parameter] public int Height { get; set; } = 50;
[Parameter] public int Width { get; set; } = 50; [Parameter] public int Width { get; set; } = 50;
[Parameter] public string SharedPlaylistId { get; set; } = string.Empty;
private bool IsCurrentTrackPlaying => AudioPlayerService.IsPlaying && AudioPlayerService.CurrentTrackId == TrackId;
private bool _isHovered; private bool _isHovered;
private void HandleMouseEnter() => _isHovered = true; private void HandleMouseEnter() => _isHovered = true;
private void HandleMouseLeave() => _isHovered = false; private void HandleMouseLeave() => _isHovered = false;
protected override void OnInitialized()
{
AudioPlayerService.OnStateChanged += OnPlayerStateChanged;
}
private async Task OnPlayClick() private async Task OnPlayClick()
{ {
await OnPlay.InvokeAsync(TrackId); var sharedPlaylistId = string.IsNullOrWhiteSpace(SharedPlaylistId) ? null : SharedPlaylistId;
if (IsCurrentTrackPlaying)
{
await AudioPlayerService.PauseAsync();
}
else
{
await AudioPlayerService.LoadAndPlayAsync(TrackId, sharedPlaylistId: SharedPlaylistId);
}
}
private void OnPlayerStateChanged()
{
InvokeAsync(StateHasChanged);
} }
private string FormatCoverUrl(string? url) private string FormatCoverUrl(string? url)

View File

@@ -1,6 +1,10 @@
@using Microsoft.JSInterop @using Microsoft.JSInterop
@using Microsoft.AspNetCore.Components.Authorization @inject IAudioPlayerService AudioPlayerService
@namespace PlaylistShared.Pwa.Components @inject IJSRuntime JS
@inject TokenStorage TokenStorage
@inject PlayerStorage PlayerStorage
@inject AuthenticationStateProvider AuthProvider
@inject ISnackbar Snackbar
@inject HttpClient Http @inject HttpClient Http
<MudPaper Class="pa-4" Elevation="0" Style="background-color: rgba(0,0,0,0.05); border-radius: 8px;"> <MudPaper Class="pa-4" Elevation="0" Style="background-color: rgba(0,0,0,0.05); border-radius: 8px;">
@@ -56,63 +60,35 @@
private bool _isPlaying; private bool _isPlaying;
private Timer? _progressTimer; private Timer? _progressTimer;
private bool _isMuted; private bool _isMuted;
private string? _currentAccessToken;
private string? _currentSharedPlaylistId;
[Inject] protected IJSRuntime JS { get; set; } = null!; protected override async Task OnInitializedAsync()
[Inject] private TokenStorage TokenStorage { get; set; } = null!; {
[Inject] private PlayerStorage PlayerStorage { get; set; } = null!; AudioPlayerService.OnLoadAndPlayRequested += OnLoadAndPlay;
[Inject] private AuthenticationStateProvider AuthProvider { get; set; } = null!; AudioPlayerService.OnPlayRequested += OnPlay;
[Inject] private ISnackbar Snackbar { get; set; } = null!; AudioPlayerService.OnPauseRequested += OnPause;
AudioPlayerService.OnStopRequested += OnStop;
AudioPlayerService.OnSeekRequested += OnSeek;
AudioPlayerService.OnVolumeChangeRequested += OnVolumeChange;
/// <summary>Требовать ли авторизацию для воспроизведения (по умолчанию true).</summary> await LoadSavedVolume();
[Parameter] public bool RequireAuth { get; set; } = true; await AudioPlayerService.SetVolumeAsync(_currentVolume); // синхронизация
}
/// <summary>ID расшаренного плейлиста.</summary>
[Parameter] public string SharedPlaylistId { get; set; } = string.Empty;
/// <summary>Событие при завершении трека.</summary>
[Parameter] public EventCallback OnTrackEnded { get; set; }
protected override async Task OnAfterRenderAsync(bool firstRender) protected override async Task OnAfterRenderAsync(bool firstRender)
{ {
if (firstRender) if (firstRender)
{ {
await EnsureAudioModuleAsync(); await EnsureAudioModuleAsync();
await ChangeVolume(await PlayerStorage.GetVolumeAsync());
StateHasChanged();
} }
} }
[JSInvokable] private async Task LoadSavedVolume()
public async Task OnAudioEnded()
{ {
_isPlaying = false; var savedVolume = await PlayerStorage.GetVolumeAsync();
_currentProgress = 0; _currentVolume = savedVolume;
StopProgressTimer(); await AudioPlayerService.SetVolumeAsync(savedVolume);
if (OnTrackEnded.HasDelegate)
await OnTrackEnded.InvokeAsync();
StateHasChanged();
}
[JSInvokable]
public async Task OnTimeUpdate(double currentTime, double duration)
{
// Защита от некорректных значений
if (double.IsNaN(currentTime) || double.IsNaN(duration) || double.IsInfinity(currentTime) || double.IsInfinity(duration))
return;
if (duration <= 0) return;
_currentProgress = (currentTime / duration) * 100;
_currentTime = FormatTime(currentTime);
// Длительность не обновляем здесь
await InvokeAsync(StateHasChanged);
}
[JSInvokable]
public async Task OnDurationReady(double duration)
{
if (double.IsNaN(duration) || double.IsInfinity(duration) || duration <= 0) return;
_totalTime = FormatTime(duration);
await InvokeAsync(StateHasChanged);
} }
private async Task EnsureAudioModuleAsync() private async Task EnsureAudioModuleAsync()
@@ -123,14 +99,42 @@
_audioElement = await _audioModule.InvokeAsync<IJSObjectReference>("init", _audioId, DotNetObjectReference.Create(this)); _audioElement = await _audioModule.InvokeAsync<IJSObjectReference>("init", _audioId, DotNetObjectReference.Create(this));
} }
[JSInvokable]
public async Task OnAudioEnded()
{
AudioPlayerService.NotifyTrackEnded();
_isPlaying = false;
_currentProgress = 0;
StopProgressTimer();
StateHasChanged();
}
[JSInvokable]
public async Task OnTimeUpdate(double currentTime, double duration)
{
if (double.IsNaN(currentTime) || double.IsNaN(duration) || duration <= 0) return;
var progress = (currentTime / duration) * 100;
var currentTimeStr = FormatTime(currentTime);
var totalTimeStr = FormatTime(duration);
AudioPlayerService.UpdateProgress(progress, currentTimeStr, totalTimeStr);
_currentProgress = progress;
_currentTime = currentTimeStr;
_totalTime = totalTimeStr;
await InvokeAsync(StateHasChanged);
}
[JSInvokable]
public async Task OnDurationReady(double duration)
{
if (duration <= 0) return;
_totalTime = FormatTime(duration);
await InvokeAsync(StateHasChanged);
}
private async Task<bool> CheckAuthAsync() private async Task<bool> CheckAuthAsync()
{ {
if (!RequireAuth) return true;
var authState = await AuthProvider.GetAuthenticationStateAsync(); var authState = await AuthProvider.GetAuthenticationStateAsync();
var isAuthenticated = authState.User.Identity?.IsAuthenticated == true; if (!authState.User.Identity?.IsAuthenticated == true)
if (!isAuthenticated)
{ {
Snackbar.Add("Воспроизведение доступно только авторизованным пользователям", Severity.Warning); Snackbar.Add("Воспроизведение доступно только авторизованным пользователям", Severity.Warning);
return false; return false;
@@ -138,32 +142,30 @@
return true; return true;
} }
public async Task LoadAndPlayAsync(string trackId) private async Task OnLoadAndPlay(string trackId, string? accessToken, string? sharedPlaylistId)
{ {
if (!await CheckAuthAsync()) return; if (!await CheckAuthAsync()) return;
var tokens = await TokenStorage.GetTokensAsync(); var tokens = await TokenStorage.GetTokensAsync();
var accessToken = tokens.token; _currentAccessToken = accessToken ?? tokens.token;
_currentSharedPlaylistId = sharedPlaylistId;
if (string.IsNullOrWhiteSpace(accessToken) && string.IsNullOrWhiteSpace(SharedPlaylistId)) if (string.IsNullOrWhiteSpace(_currentAccessToken) && string.IsNullOrWhiteSpace(_currentSharedPlaylistId))
{ {
Snackbar.Add("Токен авторизации не найден. Пожалуйста, войдите заново.", Severity.Error); Snackbar.Add("Токен авторизации не найден. Пожалуйста, войдите заново.", Severity.Error);
return; return;
} }
var streamUrl = new Uri(Http.BaseAddress!, $"/api/audio/track/{trackId}").ToString(); var streamUrl = new Uri(Http.BaseAddress!, $"/api/audio/track/{trackId}").ToString();
await EnsureAudioModuleAsync(); await EnsureAudioModuleAsync();
await _audioElement!.InvokeVoidAsync("loadAndPlay", streamUrl, accessToken, SharedPlaylistId); await _audioElement!.InvokeVoidAsync("loadAndPlay", streamUrl, _currentAccessToken, _currentSharedPlaylistId);
_isPlaying = true; _isPlaying = true;
StartProgressTimer(); StartProgressTimer();
StateHasChanged(); StateHasChanged();
} }
public async Task PlayAsync() private async Task OnPlay()
{ {
if (!await CheckAuthAsync()) return;
if (_audioElement == null) return; if (_audioElement == null) return;
await _audioElement.InvokeVoidAsync("play"); await _audioElement.InvokeVoidAsync("play");
_isPlaying = true; _isPlaying = true;
@@ -171,7 +173,7 @@
StateHasChanged(); StateHasChanged();
} }
public async Task PauseAsync() private async Task OnPause()
{ {
if (_audioElement == null) return; if (_audioElement == null) return;
await _audioElement.InvokeVoidAsync("pause"); await _audioElement.InvokeVoidAsync("pause");
@@ -180,7 +182,7 @@
StateHasChanged(); StateHasChanged();
} }
public async Task StopAsync() private async Task OnStop()
{ {
if (_audioElement == null) return; if (_audioElement == null) return;
await _audioElement.InvokeVoidAsync("stop"); await _audioElement.InvokeVoidAsync("stop");
@@ -190,20 +192,7 @@
StateHasChanged(); StateHasChanged();
} }
private async Task TogglePlayPause() private async Task OnSeek(double percent)
{
if (_isPlaying)
await PauseAsync();
else
await PlayAsync();
}
private async Task Stop()
{
await StopAsync();
}
private async Task SeekTo(double value)
{ {
if (_audioElement == null) return; if (_audioElement == null) return;
try try
@@ -211,71 +200,87 @@
var duration = await _audioElement.InvokeAsync<double>("getDuration"); var duration = await _audioElement.InvokeAsync<double>("getDuration");
if (duration > 0 && !double.IsNaN(duration)) if (duration > 0 && !double.IsNaN(duration))
{ {
var newTime = (value / 100) * duration; var newTime = (percent / 100) * duration;
await _audioElement.InvokeVoidAsync("setCurrentTime", newTime); await _audioElement.InvokeVoidAsync("setCurrentTime", newTime);
} }
} }
catch (Exception ex) catch (Exception ex)
{ {
Console.WriteLine($"SeekTo error: {ex.Message}"); Console.WriteLine($"Seek error: {ex.Message}");
} }
} }
private async Task ChangeVolume(double value) private async Task OnVolumeChange(double volume)
{ {
if (_audioElement == null) return; if (_audioElement == null) return;
try try
{ {
var volume = value / 100; await _audioElement.InvokeVoidAsync("setVolume", volume / 100);
await _audioElement.InvokeVoidAsync("setVolume", volume);
_isMuted = false; _isMuted = false;
_currentVolume = value; _currentVolume = volume;
await PlayerStorage.SetVolumeAsync(value); await PlayerStorage.SetVolumeAsync(volume);
StateHasChanged(); StateHasChanged();
} }
catch (Exception ex) catch (Exception ex)
{ {
Console.WriteLine($"ChangeVolume error: {ex.Message}"); Console.WriteLine($"Volume change error: {ex.Message}");
} }
} }
private async Task TogglePlayPause()
{
if (_isPlaying)
await AudioPlayerService.PauseAsync();
else
await AudioPlayerService.PlayAsync();
}
private async Task Stop()
{
await AudioPlayerService.StopAsync();
}
private async Task SeekTo(double value)
{
await AudioPlayerService.SeekToAsync(value);
}
private async Task ChangeVolume(double value)
{
await AudioPlayerService.SetVolumeAsync(value);
}
private async Task ToggleMute() private async Task ToggleMute()
{ {
if (_audioElement == null) return;
_isMuted = !_isMuted; _isMuted = !_isMuted;
var newVolume = _isMuted ? 0 : (_currentVolume / 100); var newVolume = _isMuted ? 0 : _currentVolume;
await _audioElement.InvokeVoidAsync("setVolume", newVolume); await AudioPlayerService.SetVolumeAsync(newVolume);
StateHasChanged();
} }
private void StartProgressTimer() private void StartProgressTimer()
{ {
StopProgressTimer(); StopProgressTimer();
_progressTimer = new Timer(async _ => _progressTimer = new Timer(async _ => await UpdateProgress(), null, 0, 500);
{
await UpdateProgress();
}, null, 0, 500);
} }
private void StopProgressTimer() => _progressTimer?.Dispose(); private void StopProgressTimer() => _progressTimer?.Dispose();
private async Task UpdateProgress() private async Task UpdateProgress()
{ {
if (_audioElement == null) if (_audioElement == null) return;
{
Console.WriteLine("UpdateProgress: _audioElement is null");
return;
}
try try
{ {
var current = await _audioElement.InvokeAsync<double>("getCurrentTime"); var current = await _audioElement.InvokeAsync<double>("getCurrentTime");
var duration = await _audioElement.InvokeAsync<double>("getDuration"); var duration = await _audioElement.InvokeAsync<double>("getDuration");
if (duration > 0 && !double.IsNaN(duration) && !double.IsNaN(current)) if (duration > 0 && !double.IsNaN(duration) && !double.IsNaN(current))
{ {
_currentProgress = (current / duration) * 100; var progress = (current / duration) * 100;
_currentTime = FormatTime(current); var currentTime = FormatTime(current);
_totalTime = FormatTime(duration); var totalTime = FormatTime(duration);
AudioPlayerService.UpdateProgress(progress, currentTime, totalTime);
_currentProgress = progress;
_currentTime = currentTime;
_totalTime = totalTime;
await InvokeAsync(StateHasChanged); await InvokeAsync(StateHasChanged);
} }
} }

View File

@@ -37,9 +37,7 @@
<div style="width: 40px; height: 40px; flex-shrink: 0;"> <div style="width: 40px; height: 40px; flex-shrink: 0;">
<TrackCoverWithPlay CoverUrl="@track.CoverUri" <TrackCoverWithPlay CoverUrl="@track.CoverUri"
TrackId="@track.TrackId" TrackId="@track.TrackId"
Width="40" Height="40" Width="40" Height="40"/>
IsPlaying="@(_currentPlayingTrackId == track.TrackId && _isPlaying)"
OnPlay="PlayTrack" />
</div> </div>
<div style="flex: 1; min-width: 0;"> <div style="flex: 1; min-width: 0;">
<MudText Typo="Typo.body1" Style="font-weight: 500;">@track.Title</MudText> <MudText Typo="Typo.body1" Style="font-weight: 500;">@track.Title</MudText>
@@ -67,23 +65,12 @@
@code { @code {
[Parameter] public EventCallback<string> OnAddTrack { get; set; } [Parameter] public EventCallback<string> OnAddTrack { get; set; }
[Parameter] public EventCallback<string> OnPlayTrack { get; set; }
[Parameter] public string ShareToken { get; set; } = string.Empty; [Parameter] public string ShareToken { get; set; } = string.Empty;
[Parameter] public string? CurrentPlayingTrackId { get; set; }
[Parameter] public bool IsPlaying { get; set; }
private string _searchQuery = ""; private string _searchQuery = "";
private List<YandexTrackSearchResult> _searchResults = new(); private List<YandexTrackSearchResult> _searchResults = new();
private bool _isSearching; private bool _isSearching;
private HashSet<string> _addingTrackIds = new(); private HashSet<string> _addingTrackIds = new();
private string? _currentPlayingTrackId;
private bool _isPlaying;
protected override void OnParametersSet()
{
_currentPlayingTrackId = CurrentPlayingTrackId;
_isPlaying = IsPlaying;
}
private async Task SearchTracks() private async Task SearchTracks()
{ {
@@ -133,11 +120,6 @@
} }
} }
private async Task PlayTrack(string trackId)
{
await OnPlayTrack.InvokeAsync(trackId);
}
private string FormatDuration(long ms) private string FormatDuration(long ms)
{ {
var seconds = ms / 1000; var seconds = ms / 1000;

View File

@@ -31,9 +31,6 @@
</MudTabPanel> </MudTabPanel>
<MudTabPanel Text="Поиск" Style="padding: 16px;"> <MudTabPanel Text="Поиск" Style="padding: 16px;">
<AddTrackBySearch OnAddTrack="AddTrackById" <AddTrackBySearch OnAddTrack="AddTrackById"
OnPlayTrack="PlayTrack"
CurrentPlayingTrackId="CurrentPlayingTrackId"
IsPlaying="IsPlaying"
ShareToken="@ShareToken" /> ShareToken="@ShareToken" />
</MudTabPanel> </MudTabPanel>
</MudTabs> </MudTabs>
@@ -46,9 +43,6 @@
[Parameter] public string ShareToken { get; set; } = string.Empty; [Parameter] public string ShareToken { get; set; } = string.Empty;
[Parameter] public EventCallback OnTrackAdded { get; set; } [Parameter] public EventCallback OnTrackAdded { get; set; }
[Parameter] public EventCallback<string> OnPlayTrack { get; set; }
[Parameter] public string? CurrentPlayingTrackId { get; set; }
[Parameter] public bool IsPlaying { get; set; }
private async Task AddTrackByLink() private async Task AddTrackByLink()
{ {
@@ -99,11 +93,6 @@
} }
} }
private async Task PlayTrack(string trackId)
{
OnPlayTrack.InvokeAsync(trackId);
}
private string? ExtractTrackIdFromLink(string link) private string? ExtractTrackIdFromLink(string link)
{ {
var match = System.Text.RegularExpressions.Regex.Match(link, @"/track/(\d+)"); var match = System.Text.RegularExpressions.Regex.Match(link, @"/track/(\d+)");

View File

@@ -26,9 +26,7 @@
{ {
<TrackCoverWithPlay CoverUrl="@context.CoverUri" <TrackCoverWithPlay CoverUrl="@context.CoverUri"
TrackId="@context.Id" TrackId="@context.Id"
Width="50" Height="50" Width="50" Height="50"/>
IsPlaying="@(CurrentPlayingTrackId == context.Id && IsPlaying)"
OnPlay="PlayTrack" />
} }
else else
{ {

View File

@@ -1,3 +1,4 @@
@using PlaylistShared.Pwa.Components.Global
@inherits LayoutComponentBase @inherits LayoutComponentBase
<MudThemeProvider Theme="@_theme" IsDarkMode="_isDarkMode" /> <MudThemeProvider Theme="@_theme" IsDarkMode="_isDarkMode" />
@@ -24,6 +25,10 @@
<MudMainContent Class="pt-16 pa-4"> <MudMainContent Class="pt-16 pa-4">
@Body @Body
</MudMainContent> </MudMainContent>
<div class="fixed-player" style="position: fixed; bottom: 0; left: 0; right: 0; z-index: 1000;">
<AudioPlayer />
</div>
</MudLayout> </MudLayout>
@code { @code {

View File

@@ -34,9 +34,7 @@
{ {
<AddTrackSection ShareToken="@Token" <AddTrackSection ShareToken="@Token"
OnTrackAdded="LoadTracks" OnTrackAdded="LoadTracks"
OnPlayTrack="PlayTrack" />
CurrentPlayingTrackId="_currentTrackId"
IsPlaying="_isPlaying" />
} }
<!-- Список треков --> <!-- Список треков -->
@@ -50,16 +48,10 @@
CanPlay="@_canPlay" CanPlay="@_canPlay"
CanRemove="@_canRemove" CanRemove="@_canRemove"
CurrentPlayingTrackId="_currentTrackId" CurrentPlayingTrackId="_currentTrackId"
IsPlaying="_isPlaying" />
OnPlayTrack="PlayTrack" />
</MudCardContent> </MudCardContent>
</MudCard> </MudCard>
} }
<!-- Фиксированный плеер внизу -->
<div class="fixed-player" style="display: @(_isPlayerVisible ? "block" : "none");">
<AudioPlayer @ref="_audioPlayer" OnTrackEnded="OnTrackEnded" RequireAuth="false" SharedPlaylistId="@Token" />
</div>
</MudContainer> </MudContainer>
@@ -68,11 +60,7 @@
private int _addTrackTabIndex = 0; // 0 - ссылка, 1 - поиск private int _addTrackTabIndex = 0; // 0 - ссылка, 1 - поиск
private AudioPlayer? _audioPlayer;
private TracksTable? _tracksTableRef; private TracksTable? _tracksTableRef;
private string? _currentTrackId { get; set; }
private bool _isPlaying = false;
private bool _isPlayerVisible = false;
private SharedPlaylistDto? _playlist; private SharedPlaylistDto? _playlist;
private bool _loading = true; private bool _loading = true;
@@ -180,38 +168,4 @@
_tracksLoading = false; _tracksLoading = false;
StateHasChanged(); StateHasChanged();
} }
private async Task PlayTrack(string trackId)
{
if (_audioPlayer == null) return;
if (_currentTrackId == trackId && _isPlaying)
{
await _audioPlayer.PauseAsync();
_isPlaying = false;
}
else if (_currentTrackId == trackId && !_isPlaying)
{
await _audioPlayer.PlayAsync();
_isPlaying = true;
}
else
{
if (!string.IsNullOrEmpty(_currentTrackId) && _isPlaying)
await _audioPlayer.StopAsync();
_currentTrackId = trackId;
await _audioPlayer.LoadAndPlayAsync(trackId);
_isPlaying = true;
}
_isPlayerVisible = true;
}
private async Task OnTrackEnded()
{
_currentTrackId = null;
_isPlaying = false;
StateHasChanged();
}
} }

View File

@@ -25,6 +25,7 @@ internal class Program
builder.Services.AddScoped<AuthStateProvider>(); builder.Services.AddScoped<AuthStateProvider>();
builder.Services.AddScoped<AuthenticationStateProvider>(sp => sp.GetRequiredService<AuthStateProvider>()); builder.Services.AddScoped<AuthenticationStateProvider>(sp => sp.GetRequiredService<AuthStateProvider>());
builder.Services.AddScoped<ApiClient>(); builder.Services.AddScoped<ApiClient>();
builder.Services.AddScoped<IAudioPlayerService, AudioPlayerService>();
builder.Services.AddAuthorizationCore(); builder.Services.AddAuthorizationCore();
builder.Services.AddCascadingAuthenticationState(); builder.Services.AddCascadingAuthenticationState();

View File

@@ -0,0 +1,136 @@
using MudBlazor;
namespace PlaylistShared.Pwa.Services;
public class AudioPlayerService : IAudioPlayerService
{
private readonly TokenStorage _tokenStorage;
private readonly ISnackbar _snackbar;
private string? _currentTrackId;
private bool _isPlaying;
private double _currentVolume = 70;
private double _currentProgress;
private string _currentTime = "0:00";
private string _totalTime = "0:00";
public string? CurrentTrackId => _currentTrackId;
public bool IsPlaying => _isPlaying;
public double CurrentVolume
{
get => _currentVolume;
set
{
_currentVolume = value;
OnStateChanged?.Invoke();
}
}
public double CurrentProgress => _currentProgress;
public string CurrentTime => _currentTime;
public string TotalTime => _totalTime;
public event Action? OnStateChanged;
public AudioPlayerService(TokenStorage tokenStorage, ISnackbar snackbar)
{
_tokenStorage = tokenStorage;
_snackbar = snackbar;
}
// Внешние команды (вызываются из компонентов)
public async Task LoadAndPlayAsync(string trackId, string? accessToken = null, string? sharedPlaylistId = null)
{
// Если accessToken не передан, пытаемся получить его из хранилища
if (string.IsNullOrWhiteSpace(accessToken))
{
var tokens = await _tokenStorage.GetTokensAsync();
accessToken = tokens.token;
}
// Проверяем, есть ли чем авторизоваться
if (string.IsNullOrWhiteSpace(accessToken) && string.IsNullOrWhiteSpace(sharedPlaylistId))
{
_snackbar.Add("Не удалось воспроизвести трек: отсутствует токен авторизации или идентификатор расшаренного плейлиста.", Severity.Error);
return;
}
_currentTrackId = trackId;
_isPlaying = true;
OnStateChanged?.Invoke();
OnLoadAndPlayRequested?.Invoke(trackId, accessToken, sharedPlaylistId);
}
public async Task PlayAsync()
{
_isPlaying = true;
OnStateChanged?.Invoke();
OnPlayRequested?.Invoke();
}
public async Task PauseAsync()
{
_isPlaying = false;
OnStateChanged?.Invoke();
OnPauseRequested?.Invoke();
}
public async Task StopAsync()
{
_isPlaying = false;
_currentTrackId = null;
_currentProgress = 0;
_currentTime = "0:00";
OnStateChanged?.Invoke();
OnStopRequested?.Invoke();
}
public async Task SeekToAsync(double percent)
{
OnSeekRequested?.Invoke(percent);
}
public async Task SetVolumeAsync(double volume)
{
_currentVolume = volume;
OnStateChanged?.Invoke();
OnVolumeChangeRequested?.Invoke(volume);
}
// События для связи с реальным AudioPlayer компонентом
public event Func<string, string?, string?, Task>? OnLoadAndPlayRequested;
public event Func<Task>? OnPlayRequested;
public event Func<Task>? OnPauseRequested;
public event Func<Task>? OnStopRequested;
public event Func<double, Task>? OnSeekRequested;
public event Func<double, Task>? OnVolumeChangeRequested;
// Внутренние методы для обновления состояния из AudioPlayer
public void SetPlayingState(bool isPlaying)
{
_isPlaying = isPlaying;
OnStateChanged?.Invoke();
}
public void SetCurrentTrack(string? trackId)
{
_currentTrackId = trackId;
OnStateChanged?.Invoke();
}
public void UpdateProgress(double progress, string currentTime, string totalTime)
{
_currentProgress = progress;
_currentTime = currentTime;
_totalTime = totalTime;
OnStateChanged?.Invoke();
}
public void NotifyTrackEnded()
{
_isPlaying = false;
_currentTrackId = null;
_currentProgress = 0;
_currentTime = "0:00";
OnStateChanged?.Invoke();
}
}

View File

@@ -0,0 +1,97 @@
namespace PlaylistShared.Pwa.Services;
/// <summary>
/// Глобальный сервис управления аудиоплеером.
/// Позволяет управлять воспроизведением из любого компонента.
/// </summary>
public interface IAudioPlayerService
{
// ---------- Состояние плеера (для чтения) ----------
/// <summary>ID текущего воспроизводимого трека (null, если ничего не играет).</summary>
string? CurrentTrackId { get; }
/// <summary>Играет ли в данный момент (true) или приостановлен (false).</summary>
bool IsPlaying { get; }
/// <summary>Текущая громкость (0100).</summary>
double CurrentVolume { get; set; }
/// <summary>Прогресс воспроизведения в процентах (0100).</summary>
double CurrentProgress { get; }
/// <summary>Отформатированное текущее время (мм:сс).</summary>
string CurrentTime { get; }
/// <summary>Отформатированная общая длительность (мм:сс).</summary>
string TotalTime { get; }
// ---------- Команды управления (вызываются из компонентов) ----------
/// <summary>Загрузить и начать воспроизведение трека.</summary>
/// <param name="trackId">ID трека.</param>
/// <param name="accessToken">Опциональный access-токен (если не указан, будет взят из хранилища).</param>
/// <param name="sharedPlaylistId">ID расшаренного плейлиста (для неавторизованного доступа).</param>
Task LoadAndPlayAsync(string trackId, string? accessToken = null, string? sharedPlaylistId = null);
/// <summary>Воспроизвести (если трек загружен и на паузе).</summary>
Task PlayAsync();
/// <summary>Поставить на паузу.</summary>
Task PauseAsync();
/// <summary>Остановить воспроизведение и выгрузить трек.</summary>
Task StopAsync();
/// <summary>Перемотать на указанный процент (0100).</summary>
Task SeekToAsync(double percent);
/// <summary>Установить громкость (0100).</summary>
Task SetVolumeAsync(double volume);
// ---------- События для подписки на изменения состояния ----------
/// <summary>
/// Событие, возникающее при любом изменении состояния плеера:
/// смена трека, старт/пауза/стоп, обновление прогресса, изменение громкости, окончание трека.
/// Подписывайтесь на него, чтобы перерисовывать UI (например, иконку "пауза/плей").
/// </summary>
event Action? OnStateChanged;
// ---------- События для связи с реальным компонентом AudioPlayer ----------
// (Эти события вызываются сервисом, а компонент AudioPlayer на них подписывается,
// чтобы выполнить фактические операции с HTML5 Audio.)
/// <summary>Запрос на загрузку и воспроизведение трека.</summary>
event Func<string, string?, string?, Task>? OnLoadAndPlayRequested;
/// <summary>Запрос на воспроизведение (снять с паузы).</summary>
event Func<Task>? OnPlayRequested;
/// <summary>Запрос на паузу.</summary>
event Func<Task>? OnPauseRequested;
/// <summary>Запрос на остановку и выгрузку трека.</summary>
event Func<Task>? OnStopRequested;
/// <summary>Запрос на перемотку (процент 0100).</summary>
event Func<double, Task>? OnSeekRequested;
/// <summary>Запрос на изменение громкости (0100).</summary>
event Func<double, Task>? OnVolumeChangeRequested;
// ---------- Методы для обновления состояния из AudioPlayer ----------
// (Вызываются компонентом AudioPlayer, когда реальный аудиоэлемент меняет своё состояние.)
/// <summary>Уведомить сервис о том, что трек начал или прекратил играть.</summary>
void SetPlayingState(bool isPlaying);
/// <summary>Установить ID текущего трека.</summary>
void SetCurrentTrack(string? trackId);
/// <summary>Обновить прогресс и отображаемое время.</summary>
void UpdateProgress(double progress, string currentTime, string totalTime);
/// <summary>Уведомить об окончании трека.</summary>
void NotifyTrackEnded();
}