diff --git a/Avalonia.Labs.Gif/Avalonia.Labs.Gif.csproj b/Avalonia.Labs.Gif/Avalonia.Labs.Gif.csproj new file mode 100644 index 0000000000000000000000000000000000000000..c466e9018bae9e55843d359686a03d71af092e8d --- /dev/null +++ b/Avalonia.Labs.Gif/Avalonia.Labs.Gif.csproj @@ -0,0 +1,15 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <TargetFrameworks>netstandard2.1;net6.0</TargetFrameworks> + <LangVersion>latest</LangVersion> + <IsPackable>true</IsPackable> + <AllowUnsafeBlocks>true</AllowUnsafeBlocks> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="Avalonia" Version="11.1.3" /> + <PackageReference Include="Avalonia.Skia" Version="11.1.3" /> + </ItemGroup> + +</Project> diff --git a/Avalonia.Labs.Gif/Decoding/BlockTypes.cs b/Avalonia.Labs.Gif/Decoding/BlockTypes.cs new file mode 100644 index 0000000000000000000000000000000000000000..abfd3167393f4652d615d3933aa94156eff74afb --- /dev/null +++ b/Avalonia.Labs.Gif/Decoding/BlockTypes.cs @@ -0,0 +1,9 @@ +namespace Avalonia.Labs.Gif.Decoding; + +internal enum BlockTypes +{ + Empty = 0, + Extension = 0x21, + ImageDescriptor = 0x2C, + Trailer = 0x3B, +} \ No newline at end of file diff --git a/Avalonia.Labs.Gif/Decoding/ExtensionType.cs b/Avalonia.Labs.Gif/Decoding/ExtensionType.cs new file mode 100644 index 0000000000000000000000000000000000000000..aa35e14af5c6b0b00f74d7dbcb644edddd19ce68 --- /dev/null +++ b/Avalonia.Labs.Gif/Decoding/ExtensionType.cs @@ -0,0 +1,7 @@ +namespace Avalonia.Labs.Gif.Decoding; + +internal enum ExtensionType +{ + GraphicsControl = 0xF9, + Application = 0xFF +} \ No newline at end of file diff --git a/Avalonia.Labs.Gif/Decoding/FrameDisposal.cs b/Avalonia.Labs.Gif/Decoding/FrameDisposal.cs new file mode 100644 index 0000000000000000000000000000000000000000..4f0607f12147dc02d28df2c6a652a830e7633818 --- /dev/null +++ b/Avalonia.Labs.Gif/Decoding/FrameDisposal.cs @@ -0,0 +1,9 @@ +namespace Avalonia.Labs.Gif.Decoding; + +internal enum FrameDisposal +{ + Unknown = 0, + Leave = 1, + Background = 2, + Restore = 3 +} diff --git a/Avalonia.Labs.Gif/Decoding/GifColor.cs b/Avalonia.Labs.Gif/Decoding/GifColor.cs new file mode 100644 index 0000000000000000000000000000000000000000..407673405296f927b88b5500a2890399b51fb6e5 --- /dev/null +++ b/Avalonia.Labs.Gif/Decoding/GifColor.cs @@ -0,0 +1,35 @@ +using System.Runtime.InteropServices; + +namespace Avalonia.Labs.Gif.Decoding; + +[StructLayout(LayoutKind.Explicit)] +internal readonly struct GifColor +{ + [FieldOffset(3)] + public readonly byte A; + + [FieldOffset(2)] + public readonly byte R; + + [FieldOffset(1)] + public readonly byte G; + + [FieldOffset(0)] + public readonly byte B; + + /// <summary> + /// A struct that represents a ARGB color and is aligned as + /// a BGRA bytefield in memory. + /// </summary> + /// <param name="r">Red</param> + /// <param name="g">Green</param> + /// <param name="b">Blue</param> + /// <param name="a">Alpha</param> + public GifColor(byte r, byte g, byte b, byte a = byte.MaxValue) + { + A = a; + R = r; + G = g; + B = b; + } +} diff --git a/Avalonia.Labs.Gif/Decoding/GifDecoder.cs b/Avalonia.Labs.Gif/Decoding/GifDecoder.cs new file mode 100644 index 0000000000000000000000000000000000000000..63878bf5b7ea1e2ecc75dfb94dd41568dfe73422 --- /dev/null +++ b/Avalonia.Labs.Gif/Decoding/GifDecoder.cs @@ -0,0 +1,661 @@ +// This source file's Lempel-Ziv-Welch algorithm is derived from Chromium's Android GifPlayer +// as seen here (https://github.com/chromium/chromium/blob/master/third_party/gif_player/src/jp/tomorrowkey/android/gifplayer) +// Licensed under the Apache License, Version 2.0 (https://www.apache.org/licenses/LICENSE-2.0) +// Copyright (C) 2015 The Gifplayer Authors. All Rights Reserved. + +// The rest of the source file is licensed under MIT License. +// Copyright (C) 2018 Jumar A. Macato, All Rights Reserved. + +using System; +using System.Buffers; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; +using Avalonia.Media.Imaging; +using static Avalonia.Labs.Gif.Extensions.StreamExtensions; + +namespace Avalonia.Labs.Gif.Decoding; + +internal sealed class GifDecoder : IDisposable +{ + private static readonly ReadOnlyMemory<byte> s_g87AMagic + = "GIF87a"u8.ToArray().AsMemory(); + + private static readonly ReadOnlyMemory<byte> s_g89AMagic + = "GIF89a"u8.ToArray().AsMemory(); + + private static readonly ReadOnlyMemory<byte> s_netscapeMagic + = "NETSCAPE2.0"u8.ToArray().AsMemory(); + + private static readonly TimeSpan s_frameDelayThreshold = TimeSpan.FromMilliseconds(10); + private static readonly TimeSpan s_frameDelayDefault = TimeSpan.FromMilliseconds(100); + private static readonly GifColor s_transparentColor = new(0, 0, 0, 0); + private const int MaxTempBuf = 768; + private const int MaxStackSize = 4096; + private const int MaxBits = 4097; + + private readonly Stream _fileStream; + private readonly CancellationToken _currentCtsToken; + private readonly bool _hasFrameBackups; + + private int _gctSize, _prevFrame = -1, _backupFrame = -1; + private bool _gctUsed; + + private GifRect _gifDimensions; + + private readonly int _backBufferBytes; + private GifColor[]? _bitmapBackBuffer; + + private short[]? _prefixBuf; + private byte[]? _suffixBuf; + private byte[]? _pixelStack; + private byte[]? _indexBuf; + private byte[]? _backupFrameIndexBuf; + private volatile bool _hasNewFrame; + + public GifHeader? Header { get; private set; } + + internal readonly List<GifFrame> _frames = new(); + + public PixelSize Size => new(Header?.Dimensions.Width ?? 0, Header?.Dimensions.Height ?? 0); + + public GifDecoder(Stream fileStream, CancellationToken currentCtsToken) + { + _fileStream = fileStream; + _currentCtsToken = currentCtsToken; + + ProcessHeaderData(); + ProcessFrameData(); + + if (Header != null) + Header.IterationCount = Header.Iterations switch + { + -1 => new GifRepeatBehavior { Count = 1 }, + 0 => new GifRepeatBehavior { LoopForever = true }, + > 0 => new GifRepeatBehavior { Count = Header.Iterations }, + _ => Header.IterationCount + }; + + var pixelCount = _gifDimensions.TotalPixels; + + _hasFrameBackups = _frames + .Any(f => f.FrameDisposalMethod == FrameDisposal.Restore); + + _bitmapBackBuffer = new GifColor[pixelCount]; + _indexBuf = new byte[pixelCount]; + + if (_hasFrameBackups) + _backupFrameIndexBuf = new byte[pixelCount]; + + _prefixBuf = new short[MaxStackSize]; + _suffixBuf = new byte[MaxStackSize]; + _pixelStack = new byte[MaxStackSize + 1]; + + _backBufferBytes = pixelCount * Marshal.SizeOf(typeof(GifColor)); + } + + public void Dispose() + { + _frames.Clear(); + + _bitmapBackBuffer = null; + _prefixBuf = null; + _suffixBuf = null; + _pixelStack = null; + _indexBuf = null; + _backupFrameIndexBuf = null; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private int PixelCoordinate(int x, int y) => x + y * _gifDimensions.Width; + + private static readonly (int Start, int Step)[] s_pass = [(0, 8), (4, 8), (2, 4), (1, 2)]; + + private void ClearImage() + { + if (_bitmapBackBuffer != null) + Array.Fill(_bitmapBackBuffer, s_transparentColor); + + _prevFrame = -1; + _backupFrame = -1; + } + + public void RenderFrame(int fIndex, WriteableBitmap? writeableBitmap, bool forceClear = false) + { + if (_currentCtsToken.IsCancellationRequested) + return; + + if (fIndex < 0 | fIndex >= _frames.Count) + return; + + if (_prevFrame == fIndex) + return; + + if (fIndex == 0 || forceClear || fIndex < _prevFrame) + ClearImage(); + + DisposePreviousFrame(); + + _prevFrame++; + + // render intermediate frame + for (int idx = _prevFrame; idx < fIndex; ++idx) + { + var prevFrame = _frames[idx]; + + if (prevFrame.FrameDisposalMethod == FrameDisposal.Restore) + continue; + + if (prevFrame.FrameDisposalMethod == FrameDisposal.Background) + { + ClearArea(prevFrame.Dimensions); + continue; + } + + RenderFrameAt(idx, writeableBitmap); + } + + RenderFrameAt(fIndex, writeableBitmap); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void RenderFrameAt(int idx, WriteableBitmap? writeableBitmap) + { + if(writeableBitmap is null) + return; + + var tmpB = ArrayPool<byte>.Shared.Rent(MaxTempBuf); + + var curFrame = _frames[idx]; + DecompressFrameToIndexBuffer(curFrame, _indexBuf, tmpB); + + if (_hasFrameBackups & curFrame.ShouldBackup + && _indexBuf != null && _backupFrameIndexBuf != null) + { + Buffer.BlockCopy(_indexBuf, 0, + _backupFrameIndexBuf, 0, + curFrame.Dimensions.TotalPixels); + _backupFrame = idx; + } + + DrawFrame(curFrame, _indexBuf); + + _prevFrame = idx; + _hasNewFrame = true; + + using var lockedBitmap = writeableBitmap.Lock(); + WriteBackBufToFb(lockedBitmap.Address); + + ArrayPool<byte>.Shared.Return(tmpB); + } + + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void DrawFrame(GifFrame curFrame, Memory<byte> frameIndexSpan) + { + var activeColorTable = + curFrame.IsLocalColorTableUsed ? curFrame.LocalColorTable : Header?.GlobalColorTable; + + var cX = curFrame.Dimensions.X; + var cY = curFrame.Dimensions.Y; + var cH = curFrame.Dimensions.Height; + var cW = curFrame.Dimensions.Width; + var tC = curFrame.TransparentColorIndex; + var hT = curFrame.HasTransparency; + + if (curFrame.IsInterlaced) + { + int curSrcRow = 0; + for (var i = 0; i < 4; i++) + { + var curPass = s_pass[i]; + var y = curPass.Start; + while (y < cH) + { + DrawRow(curSrcRow++, y); + y += curPass.Step; + } + } + } + else + { + for (var i = 0; i < cH; i++) + DrawRow(i, i); + } + + return; + + void DrawRow(int srcRow, int destRow) + { + // Get the starting point of the current row on frame's index stream. + var indexOffset = srcRow * cW; + + // Get the target back buffer offset from the frames coords. + var targetOffset = PixelCoordinate(cX, destRow + cY); + if (_bitmapBackBuffer == null) return; + var len = _bitmapBackBuffer.Length; + + for (var i = 0; i < cW; i++) + { + var indexColor = frameIndexSpan.Span[indexOffset + i]; + + if (activeColorTable == null || targetOffset >= len || + indexColor > activeColorTable.Length) return; + + if (!(hT & indexColor == tC)) + _bitmapBackBuffer[targetOffset] = activeColorTable[indexColor]; + + targetOffset++; + } + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void DisposePreviousFrame() + { + if (_prevFrame == -1) + return; + + var prevFrame = _frames[_prevFrame]; + + switch (prevFrame.FrameDisposalMethod) + { + case FrameDisposal.Background: + ClearArea(prevFrame.Dimensions); + break; + case FrameDisposal.Restore: + if (_hasFrameBackups && _backupFrame != -1) + DrawFrame(_frames[_backupFrame], _backupFrameIndexBuf); + else + ClearArea(prevFrame.Dimensions); + break; + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void ClearArea(GifRect area) + { + if (_bitmapBackBuffer is null) return; + + for (var y = 0; y < area.Height; y++) + { + var targetOffset = PixelCoordinate(area.X, y + area.Y); + for (var x = 0; x < area.Width; x++) + _bitmapBackBuffer[targetOffset + x] = s_transparentColor; + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void DecompressFrameToIndexBuffer(GifFrame curFrame, Span<byte> indexSpan, byte[] tempBuf) + { + if (_prefixBuf is null || _suffixBuf is null || _pixelStack is null) return; + + _fileStream.Position = curFrame.LzwStreamPosition; + var totalPixels = curFrame.Dimensions.TotalPixels; + + // Initialize GIF data stream decoder. + var dataSize = curFrame.LzwMinCodeSize; + var clear = 1 << dataSize; + var endOfInformation = clear + 1; + var available = clear + 2; + var oldCode = -1; + var codeSize = dataSize + 1; + var codeMask = (1 << codeSize) - 1; + + for (var code = 0; code < clear; code++) + { + _prefixBuf[code] = 0; + _suffixBuf[code] = (byte)code; + } + + // Decode GIF pixel stream. + int bits, first, top, pixelIndex; + var datum = bits = first = top = pixelIndex = 0; + + while (pixelIndex < totalPixels) + { + var blockSize = _fileStream.ReadBlock(tempBuf); + + if (blockSize == 0) + break; + + var blockPos = 0; + + while (blockPos < blockSize) + { + datum += tempBuf[blockPos] << bits; + blockPos++; + + bits += 8; + + while (bits >= codeSize) + { + // Get the next code. + var code = datum & codeMask; + datum >>= codeSize; + bits -= codeSize; + + // Interpret the code + if (code == clear) + { + // Reset decoder. + codeSize = dataSize + 1; + codeMask = (1 << codeSize) - 1; + available = clear + 2; + oldCode = -1; + continue; + } + + // Check for explicit end-of-stream + if (code == endOfInformation) + return; + + if (oldCode == -1) + { + indexSpan[pixelIndex++] = _suffixBuf[code]; + oldCode = code; + first = code; + continue; + } + + var inCode = code; + if (code >= available) + { + _pixelStack[top++] = (byte)first; + code = oldCode; + + if (top == MaxBits) + ThrowLswException(); + } + + while (code >= clear) + { + if (code >= MaxBits || code == _prefixBuf[code]) + ThrowLswException(); + + _pixelStack[top++] = _suffixBuf[code]; + code = _prefixBuf[code]; + + if (top == MaxBits) + ThrowLswException(); + } + + first = _suffixBuf[code]; + _pixelStack[top++] = (byte)first; + + // Add new code to the dictionary + if (available < MaxStackSize) + { + _prefixBuf[available] = (short)oldCode; + _suffixBuf[available] = (byte)first; + available++; + + if ((available & codeMask) == 0 && available < MaxStackSize) + { + codeSize++; + codeMask += available; + } + } + + oldCode = inCode; + + // Drain the pixel stack. + do + { + indexSpan[pixelIndex++] = _pixelStack[--top]; + } while (top > 0); + } + } + } + + while (pixelIndex < totalPixels) + indexSpan[pixelIndex++] = 0; // clear missing pixels + } + + private static void ThrowLswException() => throw new LzwDecompressionException(); + + /// <summary> + /// Directly copies the <see cref="GifColor"/> struct array to a bitmap IntPtr. + /// </summary> + private void WriteBackBufToFb(IntPtr targetPointer) + { + if (_currentCtsToken.IsCancellationRequested) + return; + + if (!(_hasNewFrame && _bitmapBackBuffer != null)) return; + + unsafe + { + fixed (void* src = &_bitmapBackBuffer[0]) + Buffer.MemoryCopy(src, targetPointer.ToPointer(), (uint)_backBufferBytes, + (uint)_backBufferBytes); + _hasNewFrame = false; + } + } + + /// <summary> + /// Processes GIF Header. + /// </summary> + private void ProcessHeaderData() + { + var str = _fileStream; + var tmpB = ArrayPool<byte>.Shared.Rent(MaxTempBuf); + var tempBuf = tmpB.AsSpan(); + + var _ = str.Read(tmpB, 0, 6); + + if (!tempBuf[..3].SequenceEqual(s_g87AMagic[..3].Span)) + throw new InvalidGifStreamException("Not a GIF stream."); + + if (!(tempBuf[..6].SequenceEqual(s_g87AMagic.Span) | + tempBuf[..6].SequenceEqual(s_g89AMagic.Span))) + throw new InvalidGifStreamException("Unsupported GIF Version: " + + Encoding.ASCII.GetString(tempBuf[..6].ToArray())); + + ProcessScreenDescriptor(tmpB); + + Header = new GifHeader + { + Dimensions = _gifDimensions, + GlobalColorTable = + _gctUsed ? ProcessColorTable(ref str, tmpB, _gctSize) : [], + HeaderSize = _fileStream.Position + }; + + ArrayPool<byte>.Shared.Return(tmpB); + } + + /// <summary> + /// Parses colors from file stream to target color table. + /// </summary> + private static GifColor[] ProcessColorTable(ref Stream stream, byte[] rawBufSpan, int nColors) + { + var nBytes = 3 * nColors; + var target = new GifColor[nColors]; + + var n = stream.Read(rawBufSpan, 0, nBytes); + + if (n < nBytes) + throw new InvalidOperationException("Wrong color table bytes."); + + int i = 0, j = 0; + + while (i < nColors) + { + var r = rawBufSpan[j++]; + var g = rawBufSpan[j++]; + var b = rawBufSpan[j++]; + target[i++] = new GifColor(r, g, b); + } + + return target; + } + + /// <summary> + /// Parses screen and other GIF descriptors. + /// </summary> + private void ProcessScreenDescriptor(byte[] tempBuf) + { + var width = _fileStream.ReadUShortS(tempBuf); + var height = _fileStream.ReadUShortS(tempBuf); + + var packed = _fileStream.ReadByteS(tempBuf); + + _gctUsed = (packed & 0x80) != 0; + _gctSize = 2 << (packed & 7); + _ = _fileStream.ReadByteS(tempBuf); + + _gifDimensions = new GifRect(0, 0, width, height); + _fileStream.Skip(1); + } + + /// <summary> + /// Parses all frame data. + /// </summary> + private void ProcessFrameData() + { + _fileStream.Position = Header?.HeaderSize ?? -1; + + var tempBuf = ArrayPool<byte>.Shared.Rent(MaxTempBuf); + + var terminate = false; + var curFrame = 0; + + _frames.Add(new GifFrame()); + + do + { + var blockType = (BlockTypes)_fileStream.ReadByteS(tempBuf); + + switch (blockType) + { + case BlockTypes.Empty: + break; + + case BlockTypes.Extension: + ProcessExtensions(ref curFrame, tempBuf); + break; + + case BlockTypes.ImageDescriptor: + ProcessImageDescriptor(ref curFrame, tempBuf); + _fileStream.SkipBlocks(tempBuf); + break; + + case BlockTypes.Trailer: + _frames.RemoveAt(_frames.Count - 1); + terminate = true; + break; + + default: + _fileStream.SkipBlocks(tempBuf); + break; + } + + // Break the loop when the stream is not valid anymore. + if (_fileStream.Position >= _fileStream.Length & terminate == false) + throw new InvalidProgramException("Reach the end of the filestream without trailer block."); + } while (!terminate); + + ArrayPool<byte>.Shared.Return(tempBuf); + } + + /// <summary> + /// Parses GIF Image Descriptor Block. + /// </summary> + private void ProcessImageDescriptor(ref int curFrame, byte[] tempBuf) + { + var str = _fileStream; + var currentFrame = _frames[curFrame]; + + // Parse frame dimensions. + var frameX = str.ReadUShortS(tempBuf); + var frameY = str.ReadUShortS(tempBuf); + var frameW = str.ReadUShortS(tempBuf); + var frameH = str.ReadUShortS(tempBuf); + + frameW = (ushort)Math.Min(frameW, _gifDimensions.Width - frameX); + frameH = (ushort)Math.Min(frameH, _gifDimensions.Height - frameY); + + currentFrame.Dimensions = new GifRect(frameX, frameY, frameW, frameH); + + // Unpack interlace and lct info. + var packed = str.ReadByteS(tempBuf); + currentFrame.IsInterlaced = (packed & 0x40) != 0; + currentFrame.IsLocalColorTableUsed = (packed & 0x80) != 0; + currentFrame.LocalColorTableSize = (int)Math.Pow(2, (packed & 0x07) + 1); + + if (currentFrame.IsLocalColorTableUsed) + currentFrame.LocalColorTable = + ProcessColorTable(ref str, tempBuf, currentFrame.LocalColorTableSize); + + currentFrame.LzwMinCodeSize = str.ReadByteS(tempBuf); + currentFrame.LzwStreamPosition = str.Position; + + curFrame += 1; + _frames.Add(new GifFrame()); + } + + /// <summary> + /// Parses GIF Extension Blocks. + /// </summary> + private void ProcessExtensions(ref int curFrame, byte[] tempBuf) + { + var extType = (ExtensionType)_fileStream.ReadByteS(tempBuf); + + switch (extType) + { + case ExtensionType.GraphicsControl: + + _fileStream.ReadBlock(tempBuf); + var currentFrame = _frames[curFrame]; + var packed = tempBuf[0]; + + currentFrame.FrameDisposalMethod = (FrameDisposal)((packed & 0x1c) >> 2); + + if (currentFrame.FrameDisposalMethod != FrameDisposal.Restore + && currentFrame.FrameDisposalMethod != FrameDisposal.Background) + currentFrame.ShouldBackup = true; + + currentFrame.HasTransparency = (packed & 1) != 0; + + currentFrame.FrameDelay = + TimeSpan.FromMilliseconds(SpanToShort(tempBuf.AsSpan(1)) * 10); + + if (currentFrame.FrameDelay <= s_frameDelayThreshold) + currentFrame.FrameDelay = s_frameDelayDefault; + + currentFrame.TransparentColorIndex = tempBuf[3]; + break; + + case ExtensionType.Application: + var blockLen = _fileStream.ReadBlock(tempBuf); + var _ = tempBuf.AsSpan(0, blockLen); + var blockHeader = tempBuf.AsSpan(0, s_netscapeMagic.Length); + + if (blockHeader.SequenceEqual(s_netscapeMagic.Span)) + { + var count = 1; + + while (count > 0) + count = _fileStream.ReadBlock(tempBuf); + + var iterationCount = SpanToShort(tempBuf.AsSpan(1)); + + if (Header != null) + Header.Iterations = iterationCount; + } + else + _fileStream.SkipBlocks(tempBuf); + + break; + + default: + _fileStream.SkipBlocks(tempBuf); + break; + } + } +} diff --git a/Avalonia.Labs.Gif/Decoding/GifFrame.cs b/Avalonia.Labs.Gif/Decoding/GifFrame.cs new file mode 100644 index 0000000000000000000000000000000000000000..e10cbb6345f8ce1fa8eda5a9f23f338630913a89 --- /dev/null +++ b/Avalonia.Labs.Gif/Decoding/GifFrame.cs @@ -0,0 +1,16 @@ +using System; + +namespace Avalonia.Labs.Gif.Decoding; + +internal class GifFrame +{ + public bool HasTransparency, IsInterlaced, IsLocalColorTableUsed; + public byte TransparentColorIndex; + public int LzwMinCodeSize, LocalColorTableSize; + public long LzwStreamPosition; + public TimeSpan FrameDelay; + public FrameDisposal FrameDisposalMethod; + public bool ShouldBackup; + public GifRect Dimensions; + public GifColor[]? LocalColorTable; +} diff --git a/Avalonia.Labs.Gif/Decoding/GifHeader.cs b/Avalonia.Labs.Gif/Decoding/GifHeader.cs new file mode 100644 index 0000000000000000000000000000000000000000..69ae294436da5cbd3f8d94b2dbbedde582f0880b --- /dev/null +++ b/Avalonia.Labs.Gif/Decoding/GifHeader.cs @@ -0,0 +1,13 @@ +// Licensed under the MIT License. +// Copyright (C) 2018 Jumar A. Macato, All Rights Reserved. + +namespace Avalonia.Labs.Gif.Decoding; + +internal class GifHeader +{ + public long HeaderSize; + internal int Iterations = -1; + public GifRepeatBehavior? IterationCount; + public GifRect Dimensions; + public GifColor[]? GlobalColorTable; +} diff --git a/Avalonia.Labs.Gif/Decoding/GifRect.cs b/Avalonia.Labs.Gif/Decoding/GifRect.cs new file mode 100644 index 0000000000000000000000000000000000000000..d4d6f817d66ffeed6acd921bd2d3f9f92d8d5448 --- /dev/null +++ b/Avalonia.Labs.Gif/Decoding/GifRect.cs @@ -0,0 +1,45 @@ +namespace Avalonia.Labs.Gif.Decoding; + +internal readonly struct GifRect +{ + public int X { get; } + public int Y { get; } + public int Width { get; } + public int Height { get; } + public int TotalPixels { get; } + + public GifRect(int x, int y, int width, int height) + { + X = x; + Y = y; + Width = width; + Height = height; + TotalPixels = width * height; + } + + public static bool operator ==(GifRect a, GifRect b) + { + return a.X == b.X && + a.Y == b.Y && + a.Width == b.Width && + a.Height == b.Height; + } + + public static bool operator !=(GifRect a, GifRect b) + { + return !(a == b); + } + + public override bool Equals(object? obj) + { + if (obj == null || GetType() != obj.GetType()) + return false; + + return this == (GifRect)obj; + } + + public override int GetHashCode() + { + return X.GetHashCode() ^ Y.GetHashCode() | Width.GetHashCode() ^ Height.GetHashCode(); + } +} diff --git a/Avalonia.Labs.Gif/Decoding/GifRepeatBehavior.cs b/Avalonia.Labs.Gif/Decoding/GifRepeatBehavior.cs new file mode 100644 index 0000000000000000000000000000000000000000..a60799532b3c87ce3ffbbaecc37e570848075f38 --- /dev/null +++ b/Avalonia.Labs.Gif/Decoding/GifRepeatBehavior.cs @@ -0,0 +1,7 @@ +namespace Avalonia.Labs.Gif.Decoding; + +internal class GifRepeatBehavior +{ + public bool LoopForever { get; set; } + public int? Count { get; set; } +} diff --git a/Avalonia.Labs.Gif/Decoding/InvalidGifStreamException.cs b/Avalonia.Labs.Gif/Decoding/InvalidGifStreamException.cs new file mode 100644 index 0000000000000000000000000000000000000000..bc434ab971361f68b332ee575936fdc66f039b03 --- /dev/null +++ b/Avalonia.Labs.Gif/Decoding/InvalidGifStreamException.cs @@ -0,0 +1,9 @@ +// Licensed under the MIT License. +// Copyright (C) 2018 Jumar A. Macato, All Rights Reserved. + +using System; + +namespace Avalonia.Labs.Gif.Decoding; + +[Serializable] +internal sealed class InvalidGifStreamException(string message) : Exception(message); diff --git a/Avalonia.Labs.Gif/Decoding/LzwDecompressionException.cs b/Avalonia.Labs.Gif/Decoding/LzwDecompressionException.cs new file mode 100644 index 0000000000000000000000000000000000000000..e380e732fdd6fa34d07e74cf87d23c2ddf2f28f5 --- /dev/null +++ b/Avalonia.Labs.Gif/Decoding/LzwDecompressionException.cs @@ -0,0 +1,9 @@ +// Licensed under the MIT License. +// Copyright (C) 2018 Jumar A. Macato, All Rights Reserved. + +using System; + +namespace Avalonia.Labs.Gif.Decoding; + +[Serializable] +internal class LzwDecompressionException : Exception { } diff --git a/Avalonia.Labs.Gif/Extensions/StreamExtensions.cs b/Avalonia.Labs.Gif/Extensions/StreamExtensions.cs new file mode 100644 index 0000000000000000000000000000000000000000..1cbae59383bafa52eb5dd8d87796eca61ddf4d10 --- /dev/null +++ b/Avalonia.Labs.Gif/Extensions/StreamExtensions.cs @@ -0,0 +1,79 @@ +using System; +using System.IO; +using System.Runtime.CompilerServices; +using Avalonia.Labs.Gif.Decoding; + +namespace Avalonia.Labs.Gif.Extensions; + +internal static class StreamExtensions +{ + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ushort SpanToShort(Span<byte> b) => (ushort)(b[0] | (b[1] << 8)); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void Skip(this Stream stream, long count) + { + stream.Position += count; + } + + /// <summary> + /// Read a Gif block from stream while advancing the position. + /// </summary> + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int ReadBlock(this Stream stream, byte[] tempBuf) + { + _ = stream.Read(tempBuf, 0, 1); + + var blockLength = (int)tempBuf[0]; + + if (blockLength > 0) + _ = stream.Read(tempBuf, 0, blockLength); + + // Guard against infinite loop. + if (stream.Position >= stream.Length) + throw new InvalidGifStreamException("Reach the end of the filestream without trailer block."); + + return blockLength; + } + + /// <summary> + /// Skips GIF blocks until it encounters an empty block. + /// </summary> + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void SkipBlocks(this Stream stream, byte[] tempBuf) + { + int blockLength; + do + { + _ = stream.Read(tempBuf, 0, 1); + + blockLength = tempBuf[0]; + stream.Position += blockLength; + + // Guard against infinite loop. + if (stream.Position >= stream.Length) + throw new InvalidGifStreamException("Reach the end of the filestream without trailer block."); + } while (blockLength > 0); + } + + /// <summary> + /// Read a <see cref="ushort"/> from stream by providing a temporary buffer. + /// </summary> + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ushort ReadUShortS(this Stream stream, byte[] tempBuf) + { + _ = stream.Read(tempBuf, 0, 2); + return SpanToShort(tempBuf); + } + + /// <summary> + /// Read a <see cref="ushort"/> from stream by providing a temporary buffer. + /// </summary> + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static byte ReadByteS(this Stream stream, byte[] tempBuf) + { + _ = stream.Read(tempBuf, 0, 1); + var finalVal = tempBuf[0]; + return finalVal; + } +} diff --git a/Avalonia.Labs.Gif/GifCompositionCustomVisualHandler.cs b/Avalonia.Labs.Gif/GifCompositionCustomVisualHandler.cs new file mode 100644 index 0000000000000000000000000000000000000000..3a270213abdb83e0e4acdc534e8cfdf3281576d4 --- /dev/null +++ b/Avalonia.Labs.Gif/GifCompositionCustomVisualHandler.cs @@ -0,0 +1,128 @@ +using System; +using Avalonia.Media; +using Avalonia.Rendering.Composition; + +namespace Avalonia.Labs.Gif; + +internal class GifCompositionCustomVisualHandler : CompositionCustomVisualHandler +{ + private bool _running; + private Stretch? _stretch; + private StretchDirection? _stretchDirection; + private Size _GifSize; + private readonly object _sync = new(); + private bool _isDisposed; + private GifInstance? _gifInstance; + + private TimeSpan _animationElapsed; + private TimeSpan _lastServerTime; + + public override void OnMessage(object message) + { + if (message is not GifDrawPayload msg) + { + return; + } + + switch (msg) + { + case + { + HandlerCommand: HandlerCommand.Start, Source: { } uri, IterationCount: { } iteration, + Stretch: { } st, StretchDirection: { } sd + }: + { + _gifInstance = new GifInstance(uri); + + _gifInstance.IterationCount = iteration; + + _lastServerTime = CompositionNow; + _GifSize = _gifInstance.GifPixelSize.ToSize(1); + _running = true; + _stretch = st; + _stretchDirection = sd; + RegisterForNextAnimationFrameUpdate(); + break; + } + case + { + HandlerCommand: HandlerCommand.Update, Stretch: { } st, IterationCount: { } iteration, + StretchDirection: { } sd + }: + { + _stretch = st; + _stretchDirection = sd; + if (_gifInstance != null) + _gifInstance.IterationCount = iteration; + RegisterForNextAnimationFrameUpdate(); + break; + } + case { HandlerCommand: HandlerCommand.Stop }: + { + _running = false; + break; + } + case { HandlerCommand: HandlerCommand.Dispose }: + { + DisposeImpl(); + break; + } + } + } + + public override void OnAnimationFrameUpdate() + { + if (!_running || _isDisposed) + return; + + Invalidate(); + RegisterForNextAnimationFrameUpdate(); + } + + public override void OnRender(ImmediateDrawingContext context) + { + lock (_sync) + { + if (_stretch is not { } st + || _stretchDirection is not { } sd + || _gifInstance is null + || _isDisposed + || !_running) + { + return; + } + + _animationElapsed += CompositionNow - _lastServerTime; + _lastServerTime = CompositionNow; + + var bounds = GetRenderBounds().Size; + var viewPort = new Rect(bounds); + + var scale = st.CalculateScaling(bounds, _GifSize, sd); + var scaledSize = _GifSize * scale; + var destRect = viewPort + .CenterRect(new Rect(scaledSize)) + .Intersect(viewPort); + + var bitmap = _gifInstance.ProcessFrameTime(_animationElapsed); + if (bitmap is not null) + { + context.DrawBitmap(bitmap, new Rect(_gifInstance.GifPixelSize.ToSize(1)), + destRect); + } + } + } + + private void DisposeImpl() + { + lock (_sync) + { + if (_isDisposed) return; + _isDisposed = true; + _gifInstance?.Dispose(); + _animationElapsed = TimeSpan.Zero; + _lastServerTime = TimeSpan.Zero; + _running = false; + } + } +} diff --git a/Avalonia.Labs.Gif/GifDrawPayload.cs b/Avalonia.Labs.Gif/GifDrawPayload.cs new file mode 100644 index 0000000000000000000000000000000000000000..b80149f94f99bfc201c58f83fac0c88a35aa62c5 --- /dev/null +++ b/Avalonia.Labs.Gif/GifDrawPayload.cs @@ -0,0 +1,14 @@ +using System; +using Avalonia.Animation; +using Avalonia.Media; + +namespace Avalonia.Labs.Gif; + +internal record struct GifDrawPayload( + HandlerCommand HandlerCommand, + Uri? Source = default, + Size? GifSize = default, + Size? Size = default, + Stretch? Stretch = default, + StretchDirection? StretchDirection = default, + IterationCount? IterationCount = default); \ No newline at end of file diff --git a/Avalonia.Labs.Gif/GifImage.cs b/Avalonia.Labs.Gif/GifImage.cs new file mode 100644 index 0000000000000000000000000000000000000000..f060406c79ca7ed490baaccb43eb1eaffbc7985e --- /dev/null +++ b/Avalonia.Labs.Gif/GifImage.cs @@ -0,0 +1,237 @@ +using System; +using System.IO; +using System.Numerics; +using System.Threading; +using Avalonia.Animation; +using Avalonia.Controls; +using Avalonia.Labs.Gif.Decoding; +using Avalonia.Media; +using Avalonia.Platform; +using Avalonia.Rendering.Composition; + +namespace Avalonia.Labs.Gif; + +/// <summary> +/// A control that presents GIF animations. +/// </summary> +public class GifImage : Control +{ + private CompositionCustomVisual? _customVisual; + + private double _gifWidth, _gifHeight; + + /// <summary> + /// Defines the <see cref="Source"/> property. + /// </summary> + public static readonly StyledProperty<Uri> SourceProperty = + AvaloniaProperty.Register<GifImage, Uri>(nameof(Source)); + + /// <summary> + /// Defines the <see cref="IterationCount"/> property. + /// </summary> + public static readonly StyledProperty<IterationCount> IterationCountProperty = + AvaloniaProperty.Register<GifImage, IterationCount>(nameof(IterationCount), IterationCount.Infinite); + + /// <summary> + /// Defines the <see cref="StretchDirection"/> property. + /// </summary> + public static readonly StyledProperty<StretchDirection> StretchDirectionProperty = + AvaloniaProperty.Register<GifImage, StretchDirection>(nameof(StretchDirection)); + + /// <summary> + /// Defines the <see cref="Stretch"/> property. + /// </summary> + public static readonly StyledProperty<Stretch> StretchProperty = + AvaloniaProperty.Register<GifImage, Stretch>(nameof(Stretch)); + + /// <summary> + /// Gets or sets the uri pointing to the GIF image resource + /// </summary> + public Uri Source + { + get => GetValue(SourceProperty); + set => SetValue(SourceProperty, value); + } + + /// <summary> + /// Gets or sets a value controlling how the image will be stretched. + /// </summary> + public Stretch Stretch + { + get => GetValue(StretchProperty); + set => SetValue(StretchProperty, value); + } + + /// <summary> + /// Gets or sets a value controlling in what direction the image will be stretched. + /// </summary> + public StretchDirection StretchDirection + { + get => GetValue(StretchDirectionProperty); + set => SetValue(StretchDirectionProperty, value); + } + + /// <summary> + /// Gets or sets the amount in which the GIF image loops. + /// </summary> + public IterationCount IterationCount + { + get => GetValue(IterationCountProperty); + set => SetValue(IterationCountProperty, value); + } + + static GifImage() + { + AffectsRender<GifImage>(SourceProperty, + StretchProperty, + StretchDirectionProperty, + WidthProperty, + HeightProperty); + + AffectsMeasure<GifImage>(SourceProperty, + StretchProperty, + StretchDirectionProperty, + WidthProperty, + HeightProperty); + } + + private Size GetGifSize() + { + return new Size(_gifWidth, _gifHeight); + } + + /// <summary> + /// Measures the control. + /// </summary> + /// <param name="availableSize">The available size.</param> + /// <returns>The desired size of the control.</returns> + protected override Size MeasureOverride(Size availableSize) + { + return Stretch.CalculateSize(availableSize, GetGifSize(), StretchDirection); + } + + /// <inheritdoc/> + protected override Size ArrangeOverride(Size finalSize) + { + var sourceSize = GetGifSize(); + var result = Stretch.CalculateSize(finalSize, sourceSize); + return result; + } + + /// <inheritdoc /> + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + + InitializeGif(); + } + + /// <inheritdoc /> + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + var avProp = change.Property; + + if (avProp == SourceProperty) + { + InitializeGif(); + } + + if ((avProp == SourceProperty || + avProp == StretchProperty || + avProp == StretchDirectionProperty || + avProp == IterationCountProperty) && _customVisual is not null) + { + _customVisual.SendHandlerMessage( + new GifDrawPayload( + HandlerCommand.Update, + null, + GetGifSize(), + Bounds.Size, + Stretch, + StretchDirection, + IterationCount)); + } + } + + private void InitializeGif() + { + Stop(); + DisposeImpl(); + + var elemVisual = ElementComposition.GetElementVisual(this); + var compositor = elemVisual?.Compositor; + + if (compositor is null) + { + return; + } + + _customVisual = compositor.CreateCustomVisual(new GifCompositionCustomVisualHandler()); + + ElementComposition.SetElementChildVisual(this, _customVisual); + + LayoutUpdated += OnLayoutUpdated; + + _customVisual.Size = new Vector2((float)Bounds.Size.Width, (float)Bounds.Size.Height); + + using var stream = AssetLoader.Open(Source); + using var tempGifDecoder = new GifDecoder(stream, CancellationToken.None); + _gifHeight = tempGifDecoder.Size.Height; + _gifWidth = tempGifDecoder.Size.Width; + + _customVisual?.SendHandlerMessage( + new GifDrawPayload( + HandlerCommand.Start, + Source, + GetGifSize(), + Bounds.Size, + Stretch, + StretchDirection, + IterationCount)); + + InvalidateVisual(); + } + + /// <inheritdoc /> + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + LayoutUpdated -= OnLayoutUpdated; + + Stop(); + DisposeImpl(); + } + + + private void OnLayoutUpdated(object? sender, EventArgs e) + { + if (_customVisual == null) + { + return; + } + + _customVisual.Size = new Vector2((float)Bounds.Size.Width, (float)Bounds.Size.Height); + + _customVisual.SendHandlerMessage( + new GifDrawPayload( + HandlerCommand.Update, + null, + GetGifSize(), + Bounds.Size, + Stretch, + StretchDirection, + IterationCount)); + } + + private void Stop() + { + _customVisual?.SendHandlerMessage(new GifDrawPayload(HandlerCommand.Stop)); + } + + private void DisposeImpl() + { + _customVisual?.SendHandlerMessage(new GifDrawPayload(HandlerCommand.Dispose)); + _customVisual = null; + } +} diff --git a/Avalonia.Labs.Gif/GifInstance.cs b/Avalonia.Labs.Gif/GifInstance.cs new file mode 100644 index 0000000000000000000000000000000000000000..05fed9ec99cc4cdd093c82242323d846bbe43579 --- /dev/null +++ b/Avalonia.Labs.Gif/GifInstance.cs @@ -0,0 +1,128 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using Avalonia.Animation; +using Avalonia.Labs.Gif.Decoding; +using Avalonia.Media.Imaging; +using Avalonia.Platform; + +namespace Avalonia.Labs.Gif; + +internal class GifInstance : IDisposable +{ + public IterationCount IterationCount { get; set; } + private readonly GifDecoder _gifDecoder; + private readonly WriteableBitmap? _targetBitmap; + private TimeSpan _totalTime; + private readonly List<TimeSpan>? _frameTimes; + private uint _iterationCount; + private int _currentFrameIndex; + + private CancellationTokenSource CurrentCts { get; } + + internal GifInstance(object newValue) : this(newValue switch + { + Stream s => s, + _ => throw new InvalidDataException("Unsupported source object") + }) + { + } + + public GifInstance(Uri uri) : this(AssetLoader.Open(uri)) + { + } + + private GifInstance(Stream currentStream) + { + if (!currentStream.CanSeek) + throw new InvalidDataException("The provided stream is not seekable."); + + if (!currentStream.CanRead) + throw new InvalidOperationException("Can't read the stream provided."); + + currentStream.Seek(0, SeekOrigin.Begin); + + CurrentCts = new CancellationTokenSource(); + + _gifDecoder = new GifDecoder(currentStream, CurrentCts.Token); + + if (_gifDecoder.Header is null) + return; + + var pixSize = new PixelSize(_gifDecoder.Header.Dimensions.Width, _gifDecoder.Header.Dimensions.Height); + + _targetBitmap = new WriteableBitmap(pixSize, new Vector(96, 96), PixelFormat.Bgra8888, AlphaFormat.Opaque); + GifPixelSize = pixSize; + + _totalTime = TimeSpan.Zero; + + _frameTimes = _gifDecoder._frames.Select(frame => + { + _totalTime = _totalTime.Add(frame.FrameDelay); + return _totalTime; + }).ToList(); + + _gifDecoder.RenderFrame(0, _targetBitmap); + } + + public PixelSize GifPixelSize { get; } + private bool _isDisposed; + + public void Dispose() + { + if (_isDisposed) return; + + GC.SuppressFinalize(this); + + _isDisposed = true; + CurrentCts.Cancel(); + _targetBitmap?.Dispose(); + } + + public WriteableBitmap? ProcessFrameTime(TimeSpan elapsed) + { + if (!IterationCount.IsInfinite && _iterationCount > IterationCount.Value) + { + return null; + } + + if (CurrentCts.IsCancellationRequested) + { + return null; + } + + if (_frameTimes is null) + return null; + + var totalTicks = _totalTime.Ticks; + + if (totalTicks == 0) + { + return ProcessFrameIndex(0); + } + + + var elapsedTicks = elapsed.Ticks; + var timeModulus = TimeSpan.FromTicks(elapsedTicks % totalTicks); + var targetFrame = _frameTimes.FirstOrDefault(x => timeModulus < x); + var currentFrame = _frameTimes.IndexOf(targetFrame); + if (currentFrame == -1) currentFrame = 0; + + if (_currentFrameIndex == currentFrame) + return _targetBitmap; + + _iterationCount = (uint)(elapsedTicks / totalTicks); + + return ProcessFrameIndex(currentFrame); + } + + private WriteableBitmap? ProcessFrameIndex(int frameIndex) + { + _gifDecoder.RenderFrame(frameIndex, _targetBitmap); + _currentFrameIndex = frameIndex; + + return _targetBitmap; + } +} diff --git a/Avalonia.Labs.Gif/HandlerCommand.cs b/Avalonia.Labs.Gif/HandlerCommand.cs new file mode 100644 index 0000000000000000000000000000000000000000..322773016b53a2d535f45be3b1c5e2322b18366d --- /dev/null +++ b/Avalonia.Labs.Gif/HandlerCommand.cs @@ -0,0 +1,9 @@ +namespace Avalonia.Labs.Gif; + +internal enum HandlerCommand +{ + Start, + Stop, + Update, + Dispose +} \ No newline at end of file diff --git a/Blacklight.sln b/Blacklight.sln index 84453fdf82826b334aaa455d1f5bf8f88dd90395..de94322bb1ddca89d0631b73a47a4ceaae0bccb5 100644 --- a/Blacklight.sln +++ b/Blacklight.sln @@ -6,6 +6,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Blacklight.Desktop", "Black EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lightquark.NET", "Lightquark.NET\Lightquark.NET.csproj", "{354D4E75-A2CE-46D0-BB81-778ADA819951}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Avalonia.Labs.Gif", "Avalonia.Labs.Gif\Avalonia.Labs.Gif.csproj", "{354D4E75-A2CE-46D0-BB81-778ADADA9951}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -24,5 +26,7 @@ Global {354D4E75-A2CE-46D0-BB81-778ADA819951}.Debug|Any CPU.Build.0 = Debug|Any CPU {354D4E75-A2CE-46D0-BB81-778ADA819951}.Release|Any CPU.ActiveCfg = Release|Any CPU {354D4E75-A2CE-46D0-BB81-778ADA819951}.Release|Any CPU.Build.0 = Release|Any CPU + {354D4E75-A2CE-46D0-BB81-778ADADA9951}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {354D4E75-A2CE-46D0-BB81-778ADADA9951}.Debug|Any CPU.Build.0 = Debug|Any CPU EndGlobalSection EndGlobal diff --git a/Blacklight/App.axaml b/Blacklight/App.axaml index 15fe671f28eaf387aff2e87ed30d9cb03e306a9c..abc01e617299c8294a6e9532241f3672e393349c 100644 --- a/Blacklight/App.axaml +++ b/Blacklight/App.axaml @@ -35,7 +35,6 @@ <TextBlock Margin="6,0,0,0" FontSize="14" VerticalAlignment="Center">Emilia</TextBlock> </StackPanel> - <Button x:Name="PART_MenuButton" IsVisible="False" /> <Button x:Name="PART_PinButton" IsVisible="False" /> <Button x:Name="PART_MaximizeRestoreButton" IsVisible="False" /> diff --git a/Blacklight/App.axaml.cs b/Blacklight/App.axaml.cs index cf3566292f7ccbc7188da18eebe06ff9afe8340d..aa5af35c43e7cdab672fcc766b586b8e795d6cd4 100644 --- a/Blacklight/App.axaml.cs +++ b/Blacklight/App.axaml.cs @@ -1,4 +1,5 @@ using System; +using System.IO; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; @@ -26,12 +27,12 @@ public partial class App : Application { var services = new ServiceCollection(); - services.AddTransient<MainWindowViewModel>(); + services.AddSingleton<MainWindowViewModel>(); services.AddSingleton<ViewNavigationService>(); services.AddSingleton<Client>(); - services.AddTransient<ClientViewModel>(); - services.AddTransient<LoginViewModel>(); + services.AddSingleton<ClientViewModel>(); + services.AddSingleton<LoginViewModel>(); var serviceProvider = services.BuildServiceProvider(); Services = serviceProvider; @@ -43,6 +44,15 @@ public partial class App : Application client.OnLogOut = () => { mainWindowViewModel.CurrentViewModel = loginViewModel; + var path = Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "blacklight", "login.json"); + try + { + File.Delete(path); + } + catch (Exception ex) + { + Console.Error.WriteLine(ex); + } }; var clientViewModel = serviceProvider.GetRequiredService<ClientViewModel>(); diff --git a/Blacklight/Assets/loading.gif b/Blacklight/Assets/loading.gif new file mode 100644 index 0000000000000000000000000000000000000000..5261a5890e94a67ff11926df82c841715df3989b Binary files /dev/null and b/Blacklight/Assets/loading.gif differ diff --git a/Blacklight/Blacklight.csproj b/Blacklight/Blacklight.csproj index 1322419e296f429ac9bc967cfc71553cbf0be1a1..8e96b249df97cc95269cc5b0919a2c73892fbfbd 100644 --- a/Blacklight/Blacklight.csproj +++ b/Blacklight/Blacklight.csproj @@ -37,6 +37,7 @@ <ItemGroup> + <ProjectReference Include="..\Avalonia.Labs.Gif\Avalonia.Labs.Gif.csproj" /> <ProjectReference Include="..\Lightquark.NET\Lightquark.NET.csproj" /> </ItemGroup> </Project> diff --git a/Blacklight/ViewModels/LoginViewModel.cs b/Blacklight/ViewModels/LoginViewModel.cs index e39a51a9ef92c61c163fb07f7e855365402fb49e..5c3c558568816152d0046aaa32578349a61618f8 100644 --- a/Blacklight/ViewModels/LoginViewModel.cs +++ b/Blacklight/ViewModels/LoginViewModel.cs @@ -1,16 +1,20 @@ using System; +using System.IO; +using System.Threading; using System.Windows.Input; +using Avalonia.Threading; using Blacklight.Util; using Blacklight.Views.Login; using CommunityToolkit.Mvvm.Input; using Lightquark.NET; using Lightquark.Types; +using Newtonsoft.Json; namespace Blacklight.ViewModels; public class LoginViewModel : ViewModelBase { - private object _currentControl = new MainLoginPrompt(); + private object _currentControl = new Loading(); private readonly ViewNavigationService _nav; public string Network { get; set; } = "lightquark.network"; @@ -26,6 +30,7 @@ public class LoginViewModel : ViewModelBase private string _email = string.Empty; private string _password = string.Empty; private string? _loginError; + private string _loadingText = "Loading..."; public string Email { @@ -64,7 +69,43 @@ public class LoginViewModel : ViewModelBase _nav = nav; Client = client; Status = "Get network"; - SwitchNetwork(); + var path = Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "blacklight", "login.json"); + if (Path.Exists(path)) + { + try + { + var json = File.ReadAllText(path); + var persistentData = JsonConvert.DeserializeObject<LoginViewModelPersistent>(json); + LoadExistingData(persistentData!); + } + catch (Exception ex) + { + Console.Error.WriteLine(ex); + } + } + else + { + SwitchNetwork(); + _currentControl = new MainLoginPrompt(); + } + } + + public async void LoadExistingData(LoginViewModelPersistent persistentData) + { + LoadingText = "Connecting to network"; + var network = await Client.GetNetworkAsync(new Uri(persistentData.NetworkBase)); + if (network.success) + { + Client.NetworkInformation = network.networkInformation; + Client.UseToken(persistentData.AccessToken, persistentData.RefreshToken); + Console.WriteLine("Found token, showing ClientView"); + _nav.ShowView<ClientViewModel>(); + _currentControl = new MainLoginPrompt(); + } + else + { + LoadingText = $"Failed to connect to network :< ({network.error})"; + } } public object CurrentControl @@ -80,8 +121,17 @@ public class LoginViewModel : ViewModelBase public ICommand UseEquinoxCommand => new RelayCommand(UseEquinox); public ICommand UseDevCommand => new RelayCommand(UseDev); public ICommand SwitchNetworkCommand => new RelayCommand(SwitchNetwork); + + + public ICommand WaitNvmCommand => new RelayCommand(WaitNvm); + public string LoadingText + { + get => _loadingText; + set => SetProperty(ref _loadingText, value); + } + private async void Login() { @@ -97,6 +147,15 @@ public class LoginViewModel : ViewModelBase var res = await Client.AcquireToken(Email, Password); if (res.success) { + var path = Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "blacklight", "login.json"); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + await File.WriteAllTextAsync(path, JsonConvert.SerializeObject(new LoginViewModelPersistent + { + NetworkBase = Network, + AccessToken = res.accessToken!, + RefreshToken = res.refreshToken! + })); + Console.WriteLine("Logged in, showing ClientView"); _nav.ShowView<ClientViewModel>(); } else @@ -156,6 +215,11 @@ public class LoginViewModel : ViewModelBase } EnableButtons = true; } +} - +public record LoginViewModelPersistent +{ + public required string AccessToken; + public required string RefreshToken; + public required string NetworkBase; } \ No newline at end of file diff --git a/Blacklight/ViewModels/MainWindowViewModel.cs b/Blacklight/ViewModels/MainWindowViewModel.cs index 3c0333c815feb9a3609501c36ffe9075d045097d..653631d633019fe6d38f2d1d70a603d844a98151 100644 --- a/Blacklight/ViewModels/MainWindowViewModel.cs +++ b/Blacklight/ViewModels/MainWindowViewModel.cs @@ -1,7 +1,12 @@ using System; +using System.IO; +using Avalonia.Controls.Shapes; +using Avalonia.Media.Imaging; +using Avalonia.Platform; using Blacklight.Util; using Lightquark.NET; using Microsoft.Extensions.DependencyInjection; +using Path = System.IO.Path; namespace Blacklight.ViewModels; @@ -17,21 +22,43 @@ public class MainWindowViewModel : ViewModelBase } } + private Bitmap? _backgroundImage; + + public Bitmap BackgroundImage + { + get + { + if (_backgroundImage != null) return _backgroundImage; + + var path = Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "blacklight", "background.png"); + if (!Path.Exists(path)) + { + _backgroundImage = new Bitmap(AssetLoader.Open(new Uri("avares://Blacklight/Assets/hime.png"))); + return _backgroundImage; + } + + _backgroundImage = new Bitmap(path); + return _backgroundImage; + } + } + private object _currentViewModel = new LoadingViewModel(); public MainWindowViewModel(ViewNavigationService nav) { + Directory.CreateDirectory(Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "blacklight")); nav.SetMainWindowViewModel(this); } public MainWindowViewModel() { + Directory.CreateDirectory(Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "blacklight")); var services = new ServiceCollection(); - services.AddTransient<MainWindowViewModel>(_ => this); + services.AddSingleton<MainWindowViewModel>(_ => this); services.AddSingleton<Client>(); services.AddSingleton<ViewNavigationService>(); - services.AddTransient<ClientViewModel>(); - services.AddTransient<LoginViewModel>(); + services.AddSingleton<ClientViewModel>(); + services.AddSingleton<LoginViewModel>(); var serviceProvider = services.BuildServiceProvider(); App.Services = serviceProvider; diff --git a/Blacklight/Views/Login/Loading.axaml b/Blacklight/Views/Login/Loading.axaml new file mode 100644 index 0000000000000000000000000000000000000000..3f36c7a88333c1bbd9fd25c45c9d0f96808757e1 --- /dev/null +++ b/Blacklight/Views/Login/Loading.axaml @@ -0,0 +1,21 @@ +<UserControl xmlns="https://github.com/avaloniaui" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:viewModels="clr-namespace:Blacklight.ViewModels" + xmlns:gif="clr-namespace:Avalonia.Labs.Gif;assembly=Avalonia.Labs.Gif" + mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" + x:Class="Blacklight.Views.Login.Loading" + x:DataType="viewModels:LoginViewModel"> + <StackPanel HorizontalAlignment="Stretch" VerticalAlignment="Stretch"> + <gif:GifImage HorizontalAlignment="Center" VerticalAlignment="Center" Source="avares://Blacklight/Assets/loading.gif" > + <gif:GifImage.Effect> + <DropShadowEffect Color="#FFFFFF" + BlurRadius="50" + OffsetX="0" OffsetY="0" + Opacity="0.5"/> + </gif:GifImage.Effect> + </gif:GifImage> + <TextBlock HorizontalAlignment="Center" Text="{Binding LoadingText, FallbackValue='Loading...'}" /> + </StackPanel> +</UserControl> diff --git a/Blacklight/Views/Login/Loading.axaml.cs b/Blacklight/Views/Login/Loading.axaml.cs new file mode 100644 index 0000000000000000000000000000000000000000..4d31b732e36fc88623fc98c0c187383bc14d6acf --- /dev/null +++ b/Blacklight/Views/Login/Loading.axaml.cs @@ -0,0 +1,13 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace Blacklight.Views.Login; + +public partial class Loading : UserControl +{ + public Loading() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/Blacklight/Views/MainWindow.axaml b/Blacklight/Views/MainWindow.axaml index bee0cffcaa287321f4603909ff424917f54b9047..93cddb0bbee85011b47940735df30d6dd25e4fb3 100644 --- a/Blacklight/Views/MainWindow.axaml +++ b/Blacklight/Views/MainWindow.axaml @@ -19,7 +19,7 @@ </Design.DataContext> <Window.Background> - <ImageBrush Stretch="UniformToFill" Source="avares://Blacklight/Assets/hime.png"></ImageBrush> + <ImageBrush Stretch="UniformToFill" Source="{Binding BackgroundImage}"></ImageBrush> </Window.Background> <Window.Resources> diff --git a/Lightquark.NET/Client.cs b/Lightquark.NET/Client.cs index 307ef95b6afddcac4fb479198e5989dc287c5054..28731d306fa971841f78f101a1ae37768e6f9b3e 100644 --- a/Lightquark.NET/Client.cs +++ b/Lightquark.NET/Client.cs @@ -59,6 +59,7 @@ public partial class Client : ObservableObject var expiryTime = Base36Converter.ConvertFrom(expirationTimePart); var origin = new DateTimeOffset(1970, 1, 1, 0, 0, 0, 0, TimeSpan.Zero); var expiryDate = origin.AddMilliseconds(expiryTime); + Console.WriteLine($"Token expires at {expiryDate}"); var refreshTime = expiryDate.Subtract(TimeSpan.FromMinutes(15)); var timeUntilRefresh = refreshTime - DateTimeOffset.Now; if (timeUntilRefresh < TimeSpan.FromSeconds(1)) diff --git a/Lightquark.NET/ClientMethods/Login.cs b/Lightquark.NET/ClientMethods/Login.cs index 676347c410b944cc6fa1a13c7031d674b9048212..9b77f8ef6a14475bde3d64a18ca70601bb56fb48 100644 --- a/Lightquark.NET/ClientMethods/Login.cs +++ b/Lightquark.NET/ClientMethods/Login.cs @@ -29,8 +29,7 @@ public partial class Client switch (res.StatusCode) { case HttpStatusCode.OK: - RefreshToken = parsedRes.Response.RefreshToken; - AccessToken = parsedRes.Response.AccessToken; + UseToken(parsedRes.Response.AccessToken!, parsedRes.Response.RefreshToken!); return (true, string.Empty, AccessToken, RefreshToken); case HttpStatusCode.BadRequest: case HttpStatusCode.Forbidden: @@ -47,6 +46,12 @@ public partial class Client } } + public async void UseToken(string accessToken, string refreshToken) + { + RefreshToken = refreshToken; + AccessToken = accessToken; + } + private async void AcquireToken(object? state = null) { Console.WriteLine($"Refreshing token {AccessToken} {RefreshToken}");