From 4b2b38b1ebc4a62f096593c7da3aaeb97d0c74e4 Mon Sep 17 00:00:00 2001 From: Hermanest Date: Wed, 12 Jul 2023 21:17:57 +0300 Subject: [PATCH 1/3] beatmap downloader --- BeatLeader.sln.DotSettings.user | 3 + Source/2_Core/Models/BeatSaver/MapDetail.cs | 11 + .../Models/BeatSaver/MapDetailMetadata.cs | 9 + Source/2_Core/Models/BeatSaver/MapVersion.cs | 10 + Source/2_Core/Models/BeatSaver/UserDetail.cs | 8 + Source/2_Core/Replayer/ReplayerMenuLoader.cs | 2 +- Source/7_Utils/BeatSaverConstants.cs | 10 + Source/7_Utils/BeatSaverUtils.cs | 25 ++ Source/7_Utils/FileManager.cs | 35 +++ Source/7_Utils/WebUtils.cs | 67 +++++ .../ReplayDetail/DownloadBeatmapPanel.cs | 267 ++++++++++++++++++ .../ReplayDetail/ReplayDetailPanel.cs | 115 +++++++- .../ReplayDetail/SearchIndicator.cs | 62 ++++ Source/8_UI/ReeUIComponentV2.cs | 36 ++- .../ReplayDetail/DownloadBeatmapPanel.bsml | 52 ++++ .../ReplayDetail/ReplayDetailPanel.bsml | 6 +- .../ReplayDetail/SearchIndicator.bsml | 3 + 17 files changed, 705 insertions(+), 16 deletions(-) create mode 100644 Source/2_Core/Models/BeatSaver/MapDetail.cs create mode 100644 Source/2_Core/Models/BeatSaver/MapDetailMetadata.cs create mode 100644 Source/2_Core/Models/BeatSaver/MapVersion.cs create mode 100644 Source/2_Core/Models/BeatSaver/UserDetail.cs create mode 100644 Source/7_Utils/BeatSaverConstants.cs create mode 100644 Source/7_Utils/BeatSaverUtils.cs create mode 100644 Source/7_Utils/WebUtils.cs create mode 100644 Source/8_UI/FlowCoordinator/Components/ReplayLaunchView/BeatmapReplayLaunchPanel/ReplayDetail/DownloadBeatmapPanel.cs create mode 100644 Source/8_UI/FlowCoordinator/Components/ReplayLaunchView/BeatmapReplayLaunchPanel/ReplayDetail/SearchIndicator.cs create mode 100644 Source/9_Resources/BSML/FlowCoordinator/Components/ReplayLaunchView/BeatmapReplayLaunchPanel/ReplayDetail/DownloadBeatmapPanel.bsml create mode 100644 Source/9_Resources/BSML/FlowCoordinator/Components/ReplayLaunchView/BeatmapReplayLaunchPanel/ReplayDetail/SearchIndicator.bsml diff --git a/BeatLeader.sln.DotSettings.user b/BeatLeader.sln.DotSettings.user index cf67e1e9..1085fae0 100644 --- a/BeatLeader.sln.DotSettings.user +++ b/BeatLeader.sln.DotSettings.user @@ -1,11 +1,14 @@  INFO + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb"><ExtraRule Prefix="" Suffix="" Style="AA_BB" /></Policy> <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb"><ExtraRule Prefix="" Suffix="" Style="AA_BB" /></Policy> <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb"><ExtraRule Prefix="_" Suffix="" Style="aaBb" /><ExtraRule Prefix="" Suffix="" Style="AaBb" /></Policy> <AssemblyExplorer> <Assembly Path="C:\Users\Hermanest\Desktop\BSLegacyLauncher\Installed Versions\Beat Saber 1.29.1\Beat Saber_Data\Managed\Main.dll" /> </AssemblyExplorer> + True + True True True True diff --git a/Source/2_Core/Models/BeatSaver/MapDetail.cs b/Source/2_Core/Models/BeatSaver/MapDetail.cs new file mode 100644 index 00000000..dbaf6b30 --- /dev/null +++ b/Source/2_Core/Models/BeatSaver/MapDetail.cs @@ -0,0 +1,11 @@ +using JetBrains.Annotations; + +namespace BeatLeader.Models.BeatSaver { + [UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] + internal class MapDetail { + public string? id; + public MapDetailMetadata? metadata; + public UserDetail? uploader; + public MapVersion[]? versions; + } +} \ No newline at end of file diff --git a/Source/2_Core/Models/BeatSaver/MapDetailMetadata.cs b/Source/2_Core/Models/BeatSaver/MapDetailMetadata.cs new file mode 100644 index 00000000..28405126 --- /dev/null +++ b/Source/2_Core/Models/BeatSaver/MapDetailMetadata.cs @@ -0,0 +1,9 @@ +using JetBrains.Annotations; + +namespace BeatLeader.Models.BeatSaver { + [UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] + public class MapDetailMetadata { + public string? songName; + public string? levelAuthorName; + } +} \ No newline at end of file diff --git a/Source/2_Core/Models/BeatSaver/MapVersion.cs b/Source/2_Core/Models/BeatSaver/MapVersion.cs new file mode 100644 index 00000000..f1e94cbb --- /dev/null +++ b/Source/2_Core/Models/BeatSaver/MapVersion.cs @@ -0,0 +1,10 @@ +using JetBrains.Annotations; + +namespace BeatLeader.Models.BeatSaver { + [UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] + internal class MapVersion { + public string? hash; + public string? coverURL; + public string? downloadURL; + } +} \ No newline at end of file diff --git a/Source/2_Core/Models/BeatSaver/UserDetail.cs b/Source/2_Core/Models/BeatSaver/UserDetail.cs new file mode 100644 index 00000000..8ffc7ee1 --- /dev/null +++ b/Source/2_Core/Models/BeatSaver/UserDetail.cs @@ -0,0 +1,8 @@ +using JetBrains.Annotations; + +namespace BeatLeader.Models.BeatSaver { + [UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] + internal class UserDetail { + public string? name; + } +} \ No newline at end of file diff --git a/Source/2_Core/Replayer/ReplayerMenuLoader.cs b/Source/2_Core/Replayer/ReplayerMenuLoader.cs index 1726cdf9..1596b58f 100644 --- a/Source/2_Core/Replayer/ReplayerMenuLoader.cs +++ b/Source/2_Core/Replayer/ReplayerMenuLoader.cs @@ -254,7 +254,7 @@ public bool LoadEnvironment(ReplayLaunchData launchData, string environmentName) return true; } - private async Task LoadBeatmapAsync( + public async Task LoadBeatmapAsync( string hash, string mode, string difficulty, diff --git a/Source/7_Utils/BeatSaverConstants.cs b/Source/7_Utils/BeatSaverConstants.cs new file mode 100644 index 00000000..af09e0a2 --- /dev/null +++ b/Source/7_Utils/BeatSaverConstants.cs @@ -0,0 +1,10 @@ +namespace BeatLeader.BeatSaverAPI { + internal static class BeatSaverConstants { + public const string BEATSAVER_API_URL = "https://api.beatsaver.com"; + public const string BEATSAVER_WEBSITE_URL = "https://beatsaver.com"; + public const string BEATSAVER_CDN_URL = "https://cdn.beatsaver.com/"; + + public const string MAPS_HASH_ENDPOINT = "/maps/hash/"; + public const string MAPS_ENDPOINT = "/maps/"; + } +} \ No newline at end of file diff --git a/Source/7_Utils/BeatSaverUtils.cs b/Source/7_Utils/BeatSaverUtils.cs new file mode 100644 index 00000000..029ab220 --- /dev/null +++ b/Source/7_Utils/BeatSaverUtils.cs @@ -0,0 +1,25 @@ +using System.Threading.Tasks; +using BeatLeader.Models.BeatSaver; +using Newtonsoft.Json; +using static BeatLeader.BeatSaverAPI.BeatSaverConstants; + +namespace BeatLeader.Utils { + internal static class BeatSaverUtils { + public static async Task GetMapByHashAsync(string hash) { + return await WebUtils.SendAsync(BEATSAVER_API_URL + MAPS_HASH_ENDPOINT + hash) is { IsSuccessStatusCode: true } res ? + JsonConvert.DeserializeObject(await res.Content.ReadAsStringAsync()) : null; + } + + public static string CreateDownloadMapUrl(string mapHash) { + return $"{BEATSAVER_CDN_URL}{mapHash.ToLower()}.zip"; + } + + public static string CreateMapPageUrl(string bsr) { + return $"{BEATSAVER_WEBSITE_URL}{MAPS_ENDPOINT}{bsr}"; + } + + public static string FormatBeatmapFolderName(string? id, string? songName, string? authorName, string? hash) { + return $"{id} ({songName} - {authorName}) [{hash}]"; + } + } +} \ No newline at end of file diff --git a/Source/7_Utils/FileManager.cs b/Source/7_Utils/FileManager.cs index 4d2156ff..cb9f345f 100644 --- a/Source/7_Utils/FileManager.cs +++ b/Source/7_Utils/FileManager.cs @@ -1,13 +1,48 @@ using System; using System.Collections.Generic; using System.IO; +using System.IO.Compression; using System.Text; using System.Text.RegularExpressions; +using System.Threading.Tasks; +using BeatLeader.Interop; using BeatLeader.Models.Activity; +using BeatLeader.Models.BeatSaver; using BeatLeader.Models.Replay; +using UnityEngine; namespace BeatLeader.Utils { internal static class FileManager { + #region Beatmaps + + private static string BeatmapsDirectory => Path.Combine(Application.dataPath, "CustomLevels"); + + public static async Task InstallBeatmap(byte[] bytes, string folderName) { + try { + var path = Path.Combine(BeatmapsDirectory, folderName); + using var memoryStream = new MemoryStream(bytes); + using var archive = new ZipArchive(memoryStream); + foreach (var entry in archive.Entries) { + using var entryStream = entry.Open(); + var streamLength = entry.Length; + var entryBuffer = new byte[streamLength]; + var bytesRead = await entryStream.ReadAsync(entryBuffer, 0, (int)streamLength); + if (bytesRead < streamLength) throw new FileLoadException(); + var destinationPath = Path.Combine(path, entry.FullName); + Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!); + using var destinationStream = File.OpenWrite(destinationPath); + await destinationStream.WriteAsync(entryBuffer, 0, (int)streamLength); + } + SongCoreInterop.TryRefreshSongs(true); + return true; + } catch (Exception ex) { + Plugin.Log.Error("Failed to install beatmap:\n" + ex); + return false; + } + } + + #endregion + #region Replays public static IEnumerable GetAllReplayPaths() { diff --git a/Source/7_Utils/WebUtils.cs b/Source/7_Utils/WebUtils.cs new file mode 100644 index 00000000..65f65100 --- /dev/null +++ b/Source/7_Utils/WebUtils.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading.Tasks; +using JetBrains.Annotations; + +namespace BeatLeader.Utils { + [PublicAPI] + internal static class WebUtils { + public static readonly HttpClient HttpClient = new(); + + public static async Task SendRawDataRequestAsync( + string url, + Action? headersCallback = null + ) { + return await SendRawDataRequestAsync(new Uri(url), headersCallback); + } + + public static async Task SendRawDataRequestAsync( + Uri uri, + Action? headersCallback = null + ) { + return await SendAsync(uri) is { IsSuccessStatusCode: true } res + ? await res.Content.ReadAsByteArrayAsync() : null; + } + + public static async Task SendAsync( + string url, + string method = "GET", + Action? headersCallback = null + ) { + return await SendAsync(new Uri(url, UriKind.Absolute), method, headersCallback); + } + + public static async Task SendAsync( + Uri uri, + string method = "GET", + Action? headersCallback = null + ) { + var request = new HttpRequestMessage { + RequestUri = uri, + Method = new(method) + }; + headersCallback?.Invoke(request.Headers); + return await HttpClient.SendAsync(request); + } + + public static async Task SendAsync( + string url, + IDictionary headers, + string method = "GET" + ) { + return await SendAsync(new Uri(url, UriKind.Absolute), headers, method); + } + + public static async Task SendAsync( + Uri uri, + IDictionary headers, + string method = "GET" + ) { + return await SendAsync(uri, method, x => { + foreach (var item in headers) x.Add(item.Key, item.Value); + }); + } + } +} \ No newline at end of file diff --git a/Source/8_UI/FlowCoordinator/Components/ReplayLaunchView/BeatmapReplayLaunchPanel/ReplayDetail/DownloadBeatmapPanel.cs b/Source/8_UI/FlowCoordinator/Components/ReplayLaunchView/BeatmapReplayLaunchPanel/ReplayDetail/DownloadBeatmapPanel.cs new file mode 100644 index 00000000..774a29c3 --- /dev/null +++ b/Source/8_UI/FlowCoordinator/Components/ReplayLaunchView/BeatmapReplayLaunchPanel/ReplayDetail/DownloadBeatmapPanel.cs @@ -0,0 +1,267 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using BeatLeader.Models.BeatSaver; +using BeatLeader.Utils; +using BeatSaberMarkupLanguage; +using BeatSaberMarkupLanguage.Attributes; +using JetBrains.Annotations; +using TMPro; +using UnityEngine; +using UnityEngine.UI; + +namespace BeatLeader.Components { + internal class DownloadBeatmapPanel : ReeUIComponentV2 { + #region Configuration + + private const string HelpText = "* Press 'Download Map' button again to proceed \n* Press on the song to open it in browser"; + private const string WaitText = "Wait a little..."; + private const string FinishedText = "The map is installed! \nPress 'Back' to continue"; + private const string FailedText = "An error occured during installation! \nPress 'Back' to continue"; + private const string InvalidText = "Oops.. Force request failed, the version does not exist! \nPress 'Back' to continue"; + private const string ForceText = "It seems like the map has no valid version! \nAttempting to get the map from CDN..."; + + private const string FailedPanelText = "Sadly, Monke cannot find the map!"; + private const string IdlingPanelText = "Monke has no work to do!"; + + #endregion + + #region UI Components + + [UIValue("search-indicator"), UsedImplicitly] + private SearchIndicator _searchIndicator = null!; + + [UIComponent("back-button")] + private readonly Button _backButton = null!; + + [UIComponent("back-button")] + private readonly Transform _backButtonTransform = null!; + + [UIComponent("download-info-text")] + private readonly TMP_Text _downloadInfoText = null!; + + [UIComponent("map-preview-image")] + private readonly Image _mapPreviewImage = null!; + + [UIComponent("map-text")] + private readonly TMP_Text _mapText = null!; + + [UIComponent("map-bsr-text")] + private readonly TMP_Text _mapBsrText = null!; + + [UIComponent("info-panel-text")] + private readonly TMP_Text _infoPanelText = null!; + + [UIObject("info-panel")] + private readonly GameObject _infoPanelObject = null!; + + [UIObject("searching-panel")] + private readonly GameObject _searchingPanelObject = null!; + + [UIObject("download-panel")] + private readonly GameObject _downloadPanelObject = null!; + + #endregion + + #region Events + + public event Action? BackButtonClickedEvent; + public event Action? DownloadAbilityChangedEvent; + + #endregion + + #region BackText + + [UIValue("back-button-text"), UsedImplicitly] + public string BackText { + get => _backText; + set { + _backText = value; + NotifyPropertyChanged(); + } + } + + private string _backText = "Back"; + + #endregion + + #region UpdateVisibility + + private enum State { + Idling, + Searching, + SearchFailed, + ReadyToDownload, + Downloading, + DownloadFailed, + DownloadInvalid, + DownloadForce, + Downloaded + } + + private void UpdateVisibility(State state) { + _infoPanelObject.SetActive(false); + _searchingPanelObject.SetActive(false); + _downloadPanelObject.SetActive(false); + + switch (state) { + case State.Idling: + _infoPanelText.text = IdlingPanelText; + _infoPanelObject.SetActive(true); + break; + case State.Searching: + _searchingPanelObject.SetActive(true); + break; + case State.SearchFailed: + _infoPanelText.text = FailedPanelText; + _infoPanelObject.SetActive(true); + break; + case State.ReadyToDownload: + _mapPreviewImage.SetImage(_mapDetail!.versions![0].coverURL); + _mapText.text = "By " + _mapDetail.metadata!.levelAuthorName; + _mapBsrText.text = "BSR: " + _mapDetail.id; + _downloadInfoText.text = HelpText; + _downloadPanelObject.SetActive(true); + break; + case State.Downloading: + case State.DownloadFailed: + case State.DownloadInvalid: + case State.DownloadForce: + case State.Downloaded: + _downloadInfoText.text = state switch { + State.Downloading => WaitText, + State.DownloadInvalid => InvalidText, + State.DownloadForce => ForceText, + State.Downloaded => FinishedText, + _ => FailedText + }; + _downloadPanelObject.SetActive(true); + break; + } + } + + #endregion + + #region Init + + protected override void OnRootStateChange(bool active) { + if (_beatmapHash is null) { + UpdateVisibility(State.Idling); + return; + } + if (_mapDetail is not null) { + UpdateVisibility(State.ReadyToDownload); + return; + } + if (!active) return; + _ = GetBeatmapAsync(_cancellationTokenSource.Token); + } + + protected override void OnInstantiate() { + _searchIndicator = Instantiate(transform); + _searchIndicator.radius = 2; + _searchIndicator.speed = -3; + } + + protected override void OnInitialize() { + _backButtonTransform.Find("Underline")?.gameObject.SetActive(false); + _mapPreviewImage.material = Resources + .FindObjectsOfTypeAll() + .FirstOrDefault(x => x.name == "UINoGlowRoundEdge"); + } + + #endregion + + #region Web Requests + + private CancellationTokenSource _cancellationTokenSource = new(); + private MapDetail? _mapDetail; + private string? _beatmapHash; + + public void SetHash(string hash) { + if (!_backButton.interactable) { + _cancellationTokenSource.Cancel(); + _cancellationTokenSource = new(); + } + _beatmapHash = hash; + _mapDetail = null; + } + + private async Task GetBeatmapAsync(CancellationToken token) { + _backButton.interactable = false; + DownloadAbilityChangedEvent?.Invoke(false); + UpdateVisibility(State.Searching); + + _mapDetail = await BeatSaverUtils.GetMapByHashAsync(_beatmapHash!); + if (token.IsCancellationRequested) return; + + _backButton.interactable = true; + if (_mapDetail is null) { + UpdateVisibility(State.SearchFailed); + return; + } + DownloadAbilityChangedEvent?.Invoke(true); + UpdateVisibility(State.ReadyToDownload); + } + + private async Task DownloadBeatmapAsync(CancellationToken token) { + if (_mapDetail is null) return; + _backButton.interactable = false; + DownloadAbilityChangedEvent?.Invoke(false); + UpdateVisibility(State.Downloading); + //download the map + var bytes = default(byte[]?); + var downloadUrl = _mapDetail.versions?.FirstOrDefault( + x => x.hash == _beatmapHash!.ToLower())?.downloadURL; + if (downloadUrl is null) { + UpdateVisibility(State.DownloadForce); + var url = BeatSaverUtils.CreateDownloadMapUrl(_beatmapHash!); + Plugin.Log.Warn("BeatSaver response has no valid version! Sending force request to " + url); + bytes = await WebUtils.SendRawDataRequestAsync(url); + } else { + bytes = await WebUtils.SendRawDataRequestAsync(downloadUrl); + } + //check is everything ok + if (token.IsCancellationRequested) return; + if (bytes is null) { + UpdateVisibility(downloadUrl is null ? State.DownloadInvalid : State.DownloadFailed); + _backButton.interactable = true; + return; + } + //save the map + var mapMetadata = _mapDetail.metadata!; + if (!await FileManager.InstallBeatmap(bytes, BeatSaverUtils.FormatBeatmapFolderName( + _mapDetail.id, mapMetadata.songName, mapMetadata.levelAuthorName, _beatmapHash))) { + UpdateVisibility(State.DownloadFailed); + _backButton.interactable = true; + return; + } + if (token.IsCancellationRequested) return; + UpdateVisibility(State.Downloaded); + _backButton.interactable = true; + _beatmapHash = null; + } + + #endregion + + #region Callbacks + + public void NotifyDownloadButtonClicked() { + if (_mapDetail is null) return; + _ = DownloadBeatmapAsync(_cancellationTokenSource.Token); + } + + [UIAction("open-link-button-click"), UsedImplicitly] + private void HandleOpenLinkButtonClicked() { + EnvironmentUtils.OpenBrowserPage(BeatSaverUtils.CreateMapPageUrl(_mapDetail!.id!)); + } + + [UIAction("back-button-click"), UsedImplicitly] + private void HandleBackButtonClicked() { + BackButtonClickedEvent?.Invoke(); + } + + #endregion + } +} \ No newline at end of file diff --git a/Source/8_UI/FlowCoordinator/Components/ReplayLaunchView/BeatmapReplayLaunchPanel/ReplayDetail/ReplayDetailPanel.cs b/Source/8_UI/FlowCoordinator/Components/ReplayLaunchView/BeatmapReplayLaunchPanel/ReplayDetail/ReplayDetailPanel.cs index 3697590a..d1288015 100644 --- a/Source/8_UI/FlowCoordinator/Components/ReplayLaunchView/BeatmapReplayLaunchPanel/ReplayDetail/ReplayDetailPanel.cs +++ b/Source/8_UI/FlowCoordinator/Components/ReplayLaunchView/BeatmapReplayLaunchPanel/ReplayDetail/ReplayDetailPanel.cs @@ -1,21 +1,48 @@ -using System.Threading.Tasks; +using System.Threading; +using System.Threading.Tasks; +using BeatLeader.Interop; using BeatLeader.Models; +using BeatLeader.Models.Replay; using BeatLeader.Replayer; using BeatLeader.Utils; using BeatSaberMarkupLanguage.Attributes; using JetBrains.Annotations; +using UnityEngine; using static BeatLeader.Models.FileStatus; namespace BeatLeader.Components { internal class ReplayDetailPanel : ReeUIComponentV2 { + #region Configuration + + private const string WatchText = "Watch"; + private const string DownloadText = "Download map"; + + #endregion + #region UI Components [UIValue("replay-info-panel"), UsedImplicitly] private ReplayStatisticsPanel _replayStatisticsPanel = null!; + [UIValue("download-beatmap-panel"), UsedImplicitly] + private DownloadBeatmapPanel _downloadBeatmapPanel = null!; + [UIValue("player-info-panel"), UsedImplicitly] private HorizontalMiniProfileContainer _miniProfile = null!; + #endregion + + #region Action Buttons + + [UIValue("watch-button-text"), UsedImplicitly] + private string? WatchButtonText { + get => _watchButtonText; + set { + _watchButtonText = value; + NotifyPropertyChanged(); + } + } + [UIValue("watch-button-interactable"), UsedImplicitly] private bool WatchButtonInteractable { get => _watchButtonInteractable; @@ -36,57 +63,105 @@ private bool DeleteButtonInteractable { private bool _deleteButtonInteractable; private bool _watchButtonInteractable; + private string? _watchButtonText; #endregion #region Init - private ReplayerMenuLoader _menuLoader = null!; + private ReplayerMenuLoader? _menuLoader; private bool _isInitialized; - + public void Setup(ReplayerMenuLoader loader) { _menuLoader = loader; _isInitialized = true; } - + protected override void OnInstantiate() { _replayStatisticsPanel = Instantiate(transform); + _downloadBeatmapPanel = Instantiate(transform); _miniProfile = Instantiate(transform); + _replayStatisticsPanel.SetData(null, null, true, true); _miniProfile.SetPlayer(null); + _miniProfile.PlayerLoadedEvent += HandlePlayerLoaded; + _downloadBeatmapPanel.BackButtonClickedEvent += HandleDownloadMenuBackButtonClicked; + _downloadBeatmapPanel.DownloadAbilityChangedEvent += HandleDownloadAbilityChangedEvent; + + SetDownloadPanelActive(false); + } + + #endregion + + #region Download Panel + + private void SetDownloadPanelActive(bool active) { + _downloadBeatmapPanel.SetRootActive(active); + _replayStatisticsPanel.SetRootActive(!active); + _isIntoDownloadMenu = active; } #endregion #region Data + private CancellationTokenSource _cancellationTokenSource = new(); private IReplayHeader? _header; private Player? _player; + private bool _beatmapIsMissing; + private bool _isIntoDownloadMenu; + private bool _isWorking; + public void SetData(IReplayHeader? header) { + if (!_isInitialized) return; + if (_isWorking) { + _cancellationTokenSource.Cancel(); + _cancellationTokenSource = new(); + } + if (_isIntoDownloadMenu) SetDownloadPanelActive(false); + _isWorking = true; var invalid = header is null || header.FileStatus is Corrupted; _replayStatisticsPanel.SetData(null, null, invalid, header is null); _header = header; DeleteButtonInteractable = header is not null; WatchButtonInteractable = false; - if (!invalid) _ = ProcessDataAsync(header!); - else _miniProfile.SetPlayer(null); + if (!invalid) { + _ = ProcessDataAsync(header!, _cancellationTokenSource.Token); + return; + } + _miniProfile.SetPlayer(null); + _isWorking = false; } - private async Task ProcessDataAsync(IReplayHeader header) { + private async Task ProcessDataAsync(IReplayHeader header, CancellationToken token) { _miniProfile.SetPlayer(header.ReplayInfo?.playerID); DeleteButtonInteractable = false; var replay = await header.LoadReplayAsync(default); + if (token.IsCancellationRequested) return; DeleteButtonInteractable = true; var stats = default(ScoreStats?); var score = default(Score?); if (replay is not null) { - await Task.Run(() => stats = ReplayStatisticUtils.ComputeScoreStats(replay)); + await Task.Run(() => stats = ReplayStatisticUtils.ComputeScoreStats(replay), token); score = ReplayUtils.ComputeScore(replay); } + if (token.IsCancellationRequested) return; _replayStatisticsPanel.SetData(score, stats, score is null || stats is null); - WatchButtonInteractable = _isInitialized && await _menuLoader.CanLaunchReplay(header.ReplayInfo!); + await RefreshAvailabilityAsync(header.ReplayInfo!, token); + } + + private async Task RefreshAvailabilityAsync(ReplayInfo info, CancellationToken token) { + var beatmap = await _menuLoader!.LoadBeatmapAsync( + info.hash, info.mode, info.difficulty, token); + if (token.IsCancellationRequested) return; + var invalid = beatmap is null; + WatchButtonText = invalid ? DownloadText : WatchText; + WatchButtonInteractable = invalid || SongCoreInterop.ValidateRequirements(beatmap!); + _beatmapIsMissing = invalid; + if (invalid) _downloadBeatmapPanel.SetHash(info.hash); + _isWorking = false; } #endregion @@ -97,6 +172,16 @@ private void HandlePlayerLoaded(Player player) { _player = player; } + private void HandleDownloadAbilityChangedEvent(bool ableToDownload) { + WatchButtonInteractable = ableToDownload; + } + + private void HandleDownloadMenuBackButtonClicked() { + SetDownloadPanelActive(false); + _isIntoDownloadMenu = false; + _ = RefreshAvailabilityAsync(_header!.ReplayInfo!, _cancellationTokenSource.Token); + } + [UIAction("delete-button-click"), UsedImplicitly] private void HandleDeleteButtonClicked() { _header?.DeleteReplayAsync(default); @@ -104,8 +189,16 @@ private void HandleDeleteButtonClicked() { [UIAction("watch-button-click"), UsedImplicitly] private void HandleWatchButtonClicked() { - if (_header is null || _header.FileStatus is Corrupted) return; - _ = _menuLoader.StartReplayAsync(_header.LoadReplayAsync(default).Result!, _player); + if (_isIntoDownloadMenu) { + _downloadBeatmapPanel.NotifyDownloadButtonClicked(); + return; + } + if (_beatmapIsMissing) { + SetDownloadPanelActive(true); + return; + } + if (!_isInitialized || _header is null || _header.FileStatus is Corrupted) return; + _ = _menuLoader!.StartReplayAsync(_header.LoadReplayAsync(default).Result!, _player); } #endregion diff --git a/Source/8_UI/FlowCoordinator/Components/ReplayLaunchView/BeatmapReplayLaunchPanel/ReplayDetail/SearchIndicator.cs b/Source/8_UI/FlowCoordinator/Components/ReplayLaunchView/BeatmapReplayLaunchPanel/ReplayDetail/SearchIndicator.cs new file mode 100644 index 00000000..0d80b4ab --- /dev/null +++ b/Source/8_UI/FlowCoordinator/Components/ReplayLaunchView/BeatmapReplayLaunchPanel/ReplayDetail/SearchIndicator.cs @@ -0,0 +1,62 @@ +using System.Collections; +using BeatLeader.Utils; +using BeatSaberMarkupLanguage.Attributes; +using UnityEngine; + +namespace BeatLeader.Components { + internal class SearchIndicator : ReeUIComponentV2 { + #region UI Components + + [UIComponent("image")] + private readonly RectTransform _imageRect = null!; + + #endregion + + #region Init + + public float ImageSize { + set => _imageRect.sizeDelta = new(value, value); + } + + public float radius = 1f; + public float speed = 1f; + + private float _angle; + + protected override void OnRootStateChange(bool active) { + if (!active) StopAllCoroutines(); + else StartCoroutine(AnimationCoroutine()); + } + + protected override void OnInitialize() { + ImageSize = 6f; + } + + #endregion + + #region Animation + + private bool _shouldBeStopped; + + private IEnumerator AnimationCoroutine() { + while (true) { + if (_shouldBeStopped) { + _shouldBeStopped = false; + yield break; + } + _imageRect.localPosition = new Vector3( + Mathf.Cos(_angle), + Mathf.Sin(_angle) + ) * radius; + _imageRect.localEulerAngles = new Vector3( + 0, 0, + MathUtils.Map(Mathf.Sin(_angle), -1, 1, -20, 20) + ); + _angle += speed * Time.deltaTime; + yield return null; + } + } + + #endregion + } +} \ No newline at end of file diff --git a/Source/8_UI/ReeUIComponentV2.cs b/Source/8_UI/ReeUIComponentV2.cs index 21cc7163..945826d2 100644 --- a/Source/8_UI/ReeUIComponentV2.cs +++ b/Source/8_UI/ReeUIComponentV2.cs @@ -85,6 +85,8 @@ protected virtual void OnInitialize() { } protected virtual void OnDispose() { } + protected virtual void OnRootStateChange(bool active) { } + #endregion #region UnityEvents @@ -123,7 +125,7 @@ public void SetParent(Transform parent) { private State _state = State.Uninitialized; protected bool IsHierarchySet => _state == State.HierarchySet; - protected bool IsParsed => _state == State.Parsed || IsHierarchySet; + protected bool IsParsed => _state >= State.Parsed; private enum State { Uninitialized, @@ -145,11 +147,38 @@ public void ManualInit(Transform rootNode) { #endregion + #region Content + + private class ContentStateListener : MonoBehaviour { + public event Action? StateChangedEvent; + + private void OnEnable() => StateChangedEvent?.Invoke(true); + + private void OnDisable() => StateChangedEvent?.Invoke(false); + } + + public void SetRootActive(bool active) { + ValidateAndThrow(); + Content.gameObject.SetActive(active); + } + + public Transform GetRootTransform() { + ValidateAndThrow(); + return Content; + } + + #endregion + #region Parse - + protected Transform Content { get; private set; } protected virtual object ParseHost => this; + [MethodImpl(MethodImplOptions.AggressiveInlining)] + protected void ValidateAndThrow() { + if (!IsParsed) throw new UninitializedComponentException(); + } + [UIAction("#post-parse"), UsedImplicitly] private protected virtual void PostParse() { if (_state == State.Parsing) return; @@ -170,11 +199,12 @@ private void ParseSelfIfNeeded() { _state = State.Parsing; PersistentSingleton.instance.Parse(GetBsmlForType(GetType()), gameObject, ParseHost); Content = Transform.GetChild(0); + Content.gameObject.AddComponent().StateChangedEvent += OnRootStateChange; _state = State.Parsed; } private void ApplyHierarchy() { - if (_state != State.Parsed) throw new Exception("Component isn't parsed!"); + ValidateAndThrow(); Content.SetParent(Transform.parent, true); diff --git a/Source/9_Resources/BSML/FlowCoordinator/Components/ReplayLaunchView/BeatmapReplayLaunchPanel/ReplayDetail/DownloadBeatmapPanel.bsml b/Source/9_Resources/BSML/FlowCoordinator/Components/ReplayLaunchView/BeatmapReplayLaunchPanel/ReplayDetail/DownloadBeatmapPanel.bsml new file mode 100644 index 00000000..25dfe888 --- /dev/null +++ b/Source/9_Resources/BSML/FlowCoordinator/Components/ReplayLaunchView/BeatmapReplayLaunchPanel/ReplayDetail/DownloadBeatmapPanel.bsml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + +