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

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
@inject IAudioPlayerService AudioPlayerService
<div class="track-cover-container"
@onmouseenter="HandleMouseEnter"
@@ -7,7 +8,7 @@
<MudImage Src="@FormatCoverUrl(CoverUrl)" Height="@Height" Width="@Width" Class="rounded" Style="display: block;" />
@if (_isHovered || IsPlaying)
@if (_isHovered || IsCurrentTrackPlaying)
{
<div class="play-overlay"
style="position: absolute; top: 0; left: 0; right: 0; bottom: 0;
@@ -16,7 +17,7 @@
align-items: center;
justify-content: center;
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"
Size="Size.Large"
OnClick="OnPlayClick" />
@@ -27,19 +28,39 @@
@code {
[Parameter] public string CoverUrl { 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 Width { get; set; } = 50;
[Parameter] public string SharedPlaylistId { get; set; } = string.Empty;
private bool IsCurrentTrackPlaying => AudioPlayerService.IsPlaying && AudioPlayerService.CurrentTrackId == TrackId;
private bool _isHovered;
private void HandleMouseEnter() => _isHovered = true;
private void HandleMouseLeave() => _isHovered = false;
protected override void OnInitialized()
{
AudioPlayerService.OnStateChanged += OnPlayerStateChanged;
}
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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -34,9 +34,7 @@
{
<AddTrackSection ShareToken="@Token"
OnTrackAdded="LoadTracks"
OnPlayTrack="PlayTrack"
CurrentPlayingTrackId="_currentTrackId"
IsPlaying="_isPlaying" />
/>
}
<!-- Список треков -->
@@ -50,16 +48,10 @@
CanPlay="@_canPlay"
CanRemove="@_canRemove"
CurrentPlayingTrackId="_currentTrackId"
IsPlaying="_isPlaying"
OnPlayTrack="PlayTrack" />
/>
</MudCardContent>
</MudCard>
}
<!-- Фиксированный плеер внизу -->
<div class="fixed-player" style="display: @(_isPlayerVisible ? "block" : "none");">
<AudioPlayer @ref="_audioPlayer" OnTrackEnded="OnTrackEnded" RequireAuth="false" SharedPlaylistId="@Token" />
</div>
</MudContainer>
@@ -68,11 +60,7 @@
private int _addTrackTabIndex = 0; // 0 - ссылка, 1 - поиск
private AudioPlayer? _audioPlayer;
private TracksTable? _tracksTableRef;
private string? _currentTrackId { get; set; }
private bool _isPlaying = false;
private bool _isPlayerVisible = false;
private SharedPlaylistDto? _playlist;
private bool _loading = true;
@@ -180,38 +168,4 @@
_tracksLoading = false;
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<AuthenticationStateProvider>(sp => sp.GetRequiredService<AuthStateProvider>());
builder.Services.AddScoped<ApiClient>();
builder.Services.AddScoped<IAudioPlayerService, AudioPlayerService>();
builder.Services.AddAuthorizationCore();
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();
}