Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/reactive-bs-sdk/Reactive.BeatSaber/Components/Label.cs
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ float ISkewedComponent.Skew {

private TextMeshProUGUI _text = null!;
private Vector2 _lastPreferredSize;
private float _lastRequestTime;

protected override void Construct(RectTransform rect) {
_text = rect.gameObject.AddComponent<CurvedTextMeshPro>();
Expand All @@ -162,6 +163,14 @@ public Vector2 Measure(float width, MeasureMode widthMode, float height, Measure
}

private void RequestLeafRecalculationOnDirty() {
var time = Time.time;
// ReSharper disable once CompareOfFloatsByEqualityOperator
if (time == _lastRequestTime) {
return;
}

_lastRequestTime = time;

var size = _text.GetPreferredValues();

if (size == _lastPreferredSize) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using JetBrains.Annotations;
using System.Collections.Generic;
using JetBrains.Annotations;
using UnityEngine;

namespace Reactive.Components;
Expand Down Expand Up @@ -46,9 +47,11 @@ void IListCell<TItem>.Init(TItem item) {
if (!IsInitialized) {
_observableItem = new(item);
ConstructAndInit();
} else if (EqualityComparer<TItem>.Default.Equals(_observableItem!.Value, item)) {
return;
}

_observableItem!.Value = item;

OnInit(item);
}

Expand Down
177 changes: 126 additions & 51 deletions src/reactive-sdk/Reactive.Components/Extensions/GIF/ImageLoader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using B83.Image.GIF;
using JetBrains.Annotations;
using UnityEngine;
using UnityEngine.Networking;

namespace Reactive.Components;

Expand All @@ -18,81 +19,153 @@ public static class ImageLoader {
private static readonly Dictionary<string, CachedImage> images = new();
private static readonly HttpClient client = new();

private static readonly Dictionary<string, SemaphoreSlim> semaphores = new();
private static readonly object semaphoresLock = new();

/// <summary>
/// Loads an image from a remote url.
/// Loads an image from the provided location. Can be either a remote url, an assembly path or a file.
/// </summary>
/// <param name="url">A url to fetch the data from.</param>
/// <returns>A loaded image or null.</returns>
/// <param name="location">A location to load the data from.</param>
/// <param name="token">A cancellation token.</param>
public static async Task<CachedImage?> LoadImage(string location, CancellationToken token) {
if (images.TryGetValue(location, out var image)) {
var semaphore = GetSemaphore(location);
await semaphore.WaitAsync(token);

try {
if (images.TryGetValue(location, out var image)) {
return image;
}

if (IsRemote(location)) {
if (IsPotentiallyAnimated(location)) {
image = await LoadAnyRemote(location, token);
} else {
// If the image isn't animated, use an optimized request version
// to load directly to a native texture, avoiding managed allocations
image = await LoadStaticRemote(location);
}
} else {
Stream? stream = null;

if (TryGetAssembly(location, out var asm, out var asmPath)) {
//
stream = asm!.GetManifestResourceStream(asmPath);
//
} else if (File.Exists(location)) {
//
stream = File.OpenRead(location);
}

if (stream != null) {
try {
image = await LoadImageFromStream(stream, null, token);
}
finally {
stream.Dispose();
}
}
}

if (image != null) {
images[location] = image;
}

return image;
}
finally {
semaphore.Release();
}
}

var stream = await GetDataAsync(location, token);
/// <summary>
/// Loads an image from the specified buffer.
/// </summary>
/// <param name="bytes">A buffer to load from.</param>
/// <param name="token">A cancellation token.</param>
public static async Task<CachedImage?> LoadImageFromBytes(byte[] bytes, CancellationToken token) {
using var stream = new MemoryStream(bytes);

image = await LoadImage(stream, null, token);

if (image != null) {
images[location] = image;
}
return await LoadImageFromStream(stream, bytes, token);
}

return image;
public static void RemoveCached(string location) {
images.Remove(location);
}

internal static async Task<Stream> GetDataAsync(string location, CancellationToken token) {
if (location.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || location.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) {
var response = await client.GetAsync(location, token);
return await response.Content.ReadAsStreamAsync();
} else if (File.Exists(location)) {
using (FileStream fileStream = File.OpenRead(location))
using (MemoryStream memoryStream = new(new byte[fileStream.Length], true))
{
await fileStream.CopyToAsync(memoryStream);
return memoryStream;
private static SemaphoreSlim GetSemaphore(string location) {
lock (semaphoresLock) {
if (!semaphores.TryGetValue(location, out var semaphore)) {
semaphore = new(1, 1);
semaphores[location] = semaphore;
}
} else {
AssemblyFromPath(location, out Assembly asm, out string newPath);
return await GetResourceAsync(asm, newPath);

return semaphore;
}
}

internal static void AssemblyFromPath(string inputPath, out Assembly assembly, out string path) {
string[] parameters = inputPath.Split(':');
#region Remote

private static bool IsRemote(string location) {
return location.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
location.StartsWith("https://", StringComparison.OrdinalIgnoreCase);
}

private static bool IsPotentiallyAnimated(string location) {
return location.EndsWith(".gif", StringComparison.OrdinalIgnoreCase);
Comment thread
Hermanest marked this conversation as resolved.
}

private static async Task<CachedImage?> LoadStaticRemote(string location) {
using var req = UnityWebRequestTexture.GetTexture(location);

var source = new TaskCompletionSource<CachedImage?>();

req.SendWebRequest().completed += _ => {
// ReSharper disable once AccessToDisposedClosure
var tex = DownloadHandlerTexture.GetContent(req);
var sprite = SpriteUtils.CreateSprite(tex);

var cached = sprite != null ? new CachedImage(sprite) : null;

source.SetResult(cached);
};

return await source.Task;
}

private static async Task<CachedImage?> LoadAnyRemote(string location, CancellationToken token) {
using var stream = await client.GetStreamAsync(location);

return await LoadImageFromStream(stream, null, token);
}

#endregion

#region Assembly

private static bool TryGetAssembly(string location, out Assembly? assembly, out string? path) {
var parameters = location.Split(':');

switch (parameters.Length) {
case 1:
path = parameters[0];
assembly = Assembly.Load(path.Substring(0, path.IndexOf('.')));
break;
return true;
case 2:
path = parameters[1];
assembly = Assembly.Load(parameters[0]);
break;
return true;
default:
throw new Exception($"Could not process resource path {inputPath}");
assembly = null;
path = null;
return false;
}
}

internal static async Task<Stream> GetResourceAsync(Assembly asm, string resourceName) {
using Stream resourceStream = asm.GetManifestResourceStream(resourceName);
using MemoryStream memoryStream = new(new byte[resourceStream.Length], true);

await resourceStream.CopyToAsync(memoryStream);
#endregion

return memoryStream;
}
#region Stream

/// <summary>
/// Loads an image using a byte array.
/// </summary>
/// <param name="data">An array to load from.</param>
/// <returns>A loaded image or null.</returns>
public static Task<CachedImage?> LoadImage(byte[] data, CancellationToken token) {
using var stream = new MemoryStream(data);

return LoadImage(stream, data, token);
}

private static async Task<CachedImage?> LoadImage(Stream stream, byte[]? bytes, CancellationToken token) {
private static async Task<CachedImage?> LoadImageFromStream(Stream stream, byte[]? bytes, CancellationToken token) {
// Try to load as GIF first
if (await TryLoadGifImage(stream, token) is { } gif) {
return new CachedImage(gif);
Expand All @@ -110,7 +183,7 @@ internal static async Task<Stream> GetResourceAsync(Assembly asm, string resourc

return new CachedImage(sprite!);
} catch (Exception ex) {
Debug.LogWarning($"Failed to create static image: {ex.Message}");
Debug.LogWarning($"Failed to create a static image: {ex.Message}");
return null;
}
}
Expand All @@ -121,11 +194,11 @@ internal static async Task<Stream> GetResourceAsync(Assembly asm, string resourc
try {
// Important to leave open as it's just a wrapper
var reader = new BinaryReader(stream);

// Returns null if magic is invalid
return new GIFLoader().Load(reader);
} catch (Exception ex) {
Debug.LogError($"Failed to load GIF: {ex}");
Debug.LogError($"Failed to create a GIF: {ex}");

return null;
}
Expand All @@ -150,4 +223,6 @@ private static async Task<byte[]> ReadStreamToBufferAsync(Stream stream, Cancell

return buffer;
}

#endregion
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ public class ImageLoaderModule(ISpriteRenderer renderer) : IReactiveModule {
public CachedImage? LoadedImage;

private CancellationTokenSource _tokenSource = new();
private Sprite? _initialSprite;
private string? _url;
private Task? _loadTask;

public void StopLoading() {
Expand All @@ -23,15 +25,21 @@ public void StopLoading() {
}

public void LoadRemote(string url, Action? onStart, Action<bool>? onFinish) {
if (url == _url) {
return;
}

StopLoading();

LoadedImage = null;
_initialSprite = renderer.Sprite;
_url = url;
_loadTask = LoadRemoteInternal(url, onStart, onFinish, _tokenSource.Token);
}

private async Task LoadRemoteInternal(
string url,
Action? onStart,
Action? onStart,
Action<bool>? onFinish,
CancellationToken token
) {
Expand All @@ -49,15 +57,18 @@ CancellationToken token
return;
}

renderer.Sprite = image.Sprite;
if (_initialSprite == renderer.Sprite) {
renderer.Sprite = image.Sprite;
}

LoadedImage = image;

onFinish?.Invoke(true);
} catch (TaskCanceledException) {
// do nothing
} catch (Exception ex) {
Debug.LogError($"Image loading has failed: {ex}");

onFinish?.Invoke(false);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,6 @@ public static IComponentHolder<T> WithWebSource<T>(
if (ResolveModule(binder) is not { } loaderModule) {
loaderModule = new(renderer);
binder.BindModule(loaderModule);

comp.Component.WithListener(x => x.Sprite, (newSprite) => {
if (newSprite != loaderModule.LoadedImage?.Sprite) {
loaderModule.StopLoading();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How the load animation will stop now on the sprite change?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Won't stop, just won't be applied afterwards.

}
});
}

loaderModule.LoadRemote(url, onStart, onFinish);
Expand All @@ -50,6 +44,6 @@ public static T CancelWebLoading<T>(this T comp) where T : IComponentHolder<ISpr
}

private static ImageLoaderModule? ResolveModule(IReactiveModuleBinder binder) {
return binder.Modules.FirstOrDefault(x => x.GetType() == typeof(ImageLoaderModule)) as ImageLoaderModule;
return binder.Modules.OfType<ImageLoaderModule>().FirstOrDefault();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@
<HintPath>$(UnityAssembliesDir)/UnityEngine.ImageConversionModule.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="UnityEngine.UnityWebRequestTextureModule">
<HintPath>$(UnityAssembliesDir)/UnityEngine.UnityWebRequestTextureModule.dll</HintPath>
</Reference>
<Reference Include="UnityEngine.UnityWebRequestModule">
<HintPath>$(UnityAssembliesDir)/UnityEngine.UnityWebRequestModule.dll</HintPath>
</Reference>
<ProjectReference Include="../../reactive/Reactive/Reactive.csproj" />
</ItemGroup>
</Project>
Loading
Loading