diff --git a/Directory.Packages.props b/Directory.Packages.props
index bb225235..7f8b7b28 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -16,7 +16,6 @@
-
diff --git a/Knossos.NET/Assets/general/knossos-1.gif b/Knossos.NET/Assets/general/knossos-1.gif
deleted file mode 100644
index 99c878a1..00000000
Binary files a/Knossos.NET/Assets/general/knossos-1.gif and /dev/null differ
diff --git a/Knossos.NET/Assets/general/knossos-2.gif b/Knossos.NET/Assets/general/knossos-2.gif
deleted file mode 100644
index 3cbc62ec..00000000
Binary files a/Knossos.NET/Assets/general/knossos-2.gif and /dev/null differ
diff --git a/Knossos.NET/Assets/general/knossos.apng b/Knossos.NET/Assets/general/knossos.apng
new file mode 100644
index 00000000..2685d016
Binary files /dev/null and b/Knossos.NET/Assets/general/knossos.apng differ
diff --git a/Knossos.NET/Classes/APNGHelper.cs b/Knossos.NET/Classes/APNGHelper.cs
index ebdf43b1..089b0f5e 100644
--- a/Knossos.NET/Classes/APNGHelper.cs
+++ b/Knossos.NET/Classes/APNGHelper.cs
@@ -1,84 +1,605 @@
-using System;
+using Avalonia;
+using Avalonia.Media.Imaging;
+using Avalonia.Platform;
+using System;
+using System.Collections.Generic;
using System.IO;
+using System.IO.Compression;
+using System.Runtime.InteropServices;
using System.Text;
namespace Knossos.NET.Classes
{
///
- /// Helper class to read APNG files.
+ /// Parses APNG files and exposes a streaming composer that renders frames
+ /// on demand — no pre-composed pixel arrays are kept in memory.
+ ///
+ /// RAM cost at runtime:
+ /// Raw compressed IDAT/fdAT bytes for all frames (~same as file size)
+ /// One canvas buffer: W × H × 4 bytes (single live copy, reused every frame)
+ /// One extra canvas snapshot only while a DISPOSE_PREVIOUS frame is active
+ ///
+ /// Usage:
+ /// var apng = APNGHelper.ReadApng(_previewStream!);
+ /// if (apng != null && apng.Frames.Count > 0)
+ /// {
+ /// var composer = apng.CreateComposer();
+ /// var localCts = _cts;
+ /// Task.Factory.StartNew(async () =>
+ /// {
+ /// do
+ /// {
+ /// if (localCts?.IsCancellationRequested == true) break;
+ /// var bmp = composer.NextFrame(out int delayMs);
+ /// var old = ImageSource;
+ /// ImageSource = bmp;
+ /// await Task.Delay(delayMs);
+ /// old?.Dispose();
+ /// } while (true);
+ /// composer.Dispose();
+ /// });
+ /// }
///
public static class APNGHelper
{
- // PNG signature
- private static readonly byte[] PngSignature = { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A };
+ private static readonly byte[] PngSignature =
+ { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A };
+
+ private const byte DisposeOpNone = 0;
+ private const byte DisposeOpBackground = 1;
+ private const byte DisposeOpPrevious = 2;
+ private const byte BlendOpSource = 0;
+ private const byte BlendOpOver = 1;
+
+ ///
+ /// One APNG frame: only the raw compressed bytes and metadata.
+ /// Pixels are decoded on demand in .
+ ///
+ public sealed class ApngRawFrame
+ {
+ public int Width, Height, X, Y;
+ public int DelayNum, DelayDen;
+ public byte DisposeOp, BlendOp;
+ public byte[] CompressedData = Array.Empty(); // merged zlib IDAT payload
+
+ public int DelayMs
+ {
+ get
+ {
+ int den = DelayDen == 0 ? 100 : DelayDen;
+ return Math.Max(1, (int)Math.Round(DelayNum * 1000.0 / den));
+ }
+ }
+ }
+
+ ///
+ /// Parsed APNG: canvas metadata + list of raw (compressed) frames.
+ /// Pixel decoding is deferred until is used.
+ ///
+ public sealed class ApngFile
+ {
+ public int CanvasWidth { get; init; }
+ public int CanvasHeight { get; init; }
+ public int NumPlays { get; init; } // 0 = loop forever
+ public byte ColorType { get; init; }
+ public (byte R, byte G, byte B)? TransparentRgb { get; init; }
+ public List Frames { get; init; } = new();
+
+ ///
+ /// Creates a new independent composer for this APNG.
+ /// Each composer has its own canvas; call this once per animation instance.
+ ///
+ public ApngComposer CreateComposer() => new ApngComposer(this);
+ }
+
+ ///
+ /// Stateful on-the-fly APNG composer.
+ /// Maintains one canvas buffer and decodes+composites frames one at a time.
+ /// Call each tick; dispose the returned bitmap after display.
+ ///
+ public sealed class ApngComposer : IDisposable
+ {
+ private readonly ApngFile _apng;
+ private readonly byte[] _canvas; // RGBA8888, single live copy
+ private byte[]? _prevCanvas; // only alive during DISPOSE_PREVIOUS frames
+ private int _frameIndex;
+ private bool _disposed;
+
+ internal ApngComposer(ApngFile apng)
+ {
+ _apng = apng;
+ _canvas = new byte[apng.CanvasWidth * apng.CanvasHeight * 4];
+ }
+
+ /// Total number of frames in the animation.
+ public int FrameCount => _apng.Frames.Count;
+
+ /// Current frame index (0-based).
+ public int FrameIndex => _frameIndex;
+
+ /// Canvas width.
+ public int CanvasWidth => _apng.CanvasWidth;
+
+ /// Canvas height.
+ public int CanvasHeight => _apng.CanvasHeight;
+
+ ///
+ /// Composes and returns the next animation frame as a WriteableBitmap.
+ /// Automatically wraps around to frame 0 after the last frame.
+ /// The CALLER must dispose the returned bitmap after displaying it.
+ ///
+ /// Display duration for this frame in milliseconds.
+ public WriteableBitmap NextFrame(out int delayMs)
+ {
+ if (_disposed) throw new ObjectDisposedException(nameof(ApngComposer));
+
+ var raw = _apng.Frames[_frameIndex];
+ delayMs = raw.DelayMs;
+
+ // Snapshot canvas before compositing if we'll need to restore it afterward
+ if (raw.DisposeOp == DisposeOpPrevious)
+ _prevCanvas = (byte[])_canvas.Clone();
+
+ // Decode this frame's compressed pixels → RGBA8888
+ byte[] frameRgba = DecodeFrame(raw, _apng.ColorType, _apng.TransparentRgb);
+
+ // Composite onto the persistent canvas
+ CompositeFrame(_canvas, _apng.CanvasWidth, frameRgba, raw);
+
+ // Wrap canvas pixels into a WriteableBitmap for display
+ var bmp = CanvasToWriteableBitmap(_canvas, _apng.CanvasWidth, _apng.CanvasHeight);
+
+ AdvanceCanvasState(raw);
+ _frameIndex = (_frameIndex + 1) % _apng.Frames.Count;
+ return bmp;
+ }
+
+ // ─────────────────────────────────────────────────────────────────────
+ // render directly into an existing WriteableBitmap.
+ // This avoids allocating a new WriteableBitmap (and its W*H*4 buffer)
+ // on every frame. The target MUST be sized exactly
+ // CanvasWidth x CanvasHeight and use PixelFormat.Rgba8888 /
+ // AlphaFormat.Unpremul. After calling this, invalidate the visual
+ // that displays the bitmap so the new pixels are repainted.
+ // ─────────────────────────────────────────────────────────────────────
+ public void RenderNextFrameTo(WriteableBitmap target, out int delayMs)
+ {
+ if (_disposed) throw new ObjectDisposedException(nameof(ApngComposer));
+ if (target == null) throw new ArgumentNullException(nameof(target));
+ if (target.PixelSize.Width != _apng.CanvasWidth ||
+ target.PixelSize.Height != _apng.CanvasHeight)
+ {
+ throw new ArgumentException(
+ $"Target size {target.PixelSize} doesn't match canvas " +
+ $"({_apng.CanvasWidth}x{_apng.CanvasHeight}).", nameof(target));
+ }
+
+ var raw = _apng.Frames[_frameIndex];
+ delayMs = raw.DelayMs;
+
+ if (raw.DisposeOp == DisposeOpPrevious)
+ _prevCanvas = (byte[])_canvas.Clone();
+
+ byte[] frameRgba = DecodeFrame(raw, _apng.ColorType, _apng.TransparentRgb);
+ CompositeFrame(_canvas, _apng.CanvasWidth, frameRgba, raw);
+
+ // Copy the canvas straight into the existing locked framebuffer.
+ using (var fb = target.Lock())
+ Marshal.Copy(_canvas, 0, fb.Address, _canvas.Length);
+
+ AdvanceCanvasState(raw);
+ _frameIndex = (_frameIndex + 1) % _apng.Frames.Count;
+ }
+
+ /// Resets the animation back to the first frame and clears the canvas.
+ public void Reset()
+ {
+ if (_disposed) throw new ObjectDisposedException(nameof(ApngComposer));
+ Array.Clear(_canvas, 0, _canvas.Length);
+ _prevCanvas = null;
+ _frameIndex = 0;
+ }
+
+ ///
+ /// Allocates a new WriteableBitmap matching this composer's canvas, suitable
+ /// as the reusable target for .
+ ///
+ public WriteableBitmap CreateMatchingBitmap() =>
+ new WriteableBitmap(
+ new PixelSize(_apng.CanvasWidth, _apng.CanvasHeight),
+ new Vector(96, 96),
+ PixelFormat.Rgba8888,
+ AlphaFormat.Unpremul);
+
+ private void AdvanceCanvasState(ApngRawFrame raw)
+ {
+ switch (raw.DisposeOp)
+ {
+ case DisposeOpBackground:
+ ClearRegion(_canvas, _apng.CanvasWidth, raw.X, raw.Y, raw.Width, raw.Height);
+ break;
+ case DisposeOpPrevious:
+ Buffer.BlockCopy(_prevCanvas!, 0, _canvas, 0, _canvas.Length);
+ _prevCanvas = null;
+ break;
+ // DisposeOpNone: leave canvas as-is
+ }
+ }
+
+ public void Dispose()
+ {
+ _disposed = true;
+ _prevCanvas = null;
+ }
+ }
///
- /// Reads a stream to verify if it is a valid APNG file
- /// Checks for acTL chuck presence.
- /// Doesn't close or disposes the stream.
- /// Throws a exception if the stream doesn't contains a png file data
+ /// Returns true if is an APNG (has acTL chunk).
+ /// Does not close the stream. Throws if not a valid PNG.
///
- ///
- /// true if file is apng
- ///
public static bool IsApng(Stream pngStream)
{
if (pngStream == null || !pngStream.CanRead)
- throw new ArgumentException("Stream inválido.");
+ throw new ArgumentException("Invalid stream.");
- long originalPos = pngStream.Position;
+ long savedPos = pngStream.Position;
+ try
+ {
+ using var br = new BinaryReader(pngStream, Encoding.ASCII, leaveOpen: true);
+ if (!BytesEqual(br.ReadBytes(8), PngSignature))
+ throw new Exception("Not a PNG file.");
+
+ while (pngStream.Position < pngStream.Length)
+ {
+ int len = ReadBE32(br);
+ string type = ReadChunkType(br);
+ if (type == "acTL") return true;
+ pngStream.Seek(len + 4, SeekOrigin.Current);
+ if (type == "IEND") break;
+ }
+ return false;
+ }
+ finally { pngStream.Seek(savedPos, SeekOrigin.Begin); }
+ }
+ ///
+ /// Parses an APNG stream. Stores only the raw compressed frame bytes —
+ /// no pixels are decoded. Returns null on error.
+ /// Does not close the stream.
+ ///
+ public static ApngFile? ReadApng(Stream stream)
+ {
try
{
- using (var br = new BinaryReader(pngStream, Encoding.ASCII, leaveOpen: true))
+ stream.Seek(0, SeekOrigin.Begin);
+ using var br = new BinaryReader(stream, Encoding.ASCII, leaveOpen: true);
+
+ if (!BytesEqual(br.ReadBytes(8), PngSignature))
+ throw new Exception("Not a PNG file.");
+
+ byte[]? ihdrData = null;
+ byte[]? trnsData = null;
+ int numPlays = 0;
+ var frames = new List();
+ ApngRawFrame? cur = null;
+ var idatBufs = new List();
+
+ while (stream.Position < stream.Length)
{
- var sig = br.ReadBytes(8);
- if (!BytesEqual(sig, PngSignature))
- throw new Exception("Incorrect PNG Signature");
+ int len = ReadBE32(br);
+ string type = ReadChunkType(br);
+ byte[] data = br.ReadBytes(len);
+ br.ReadBytes(4); // CRC — skip
- // Look for acTL o IEND
- while (pngStream.Position < pngStream.Length)
+ switch (type)
{
- var lengthBytes = br.ReadBytes(4);
- if (lengthBytes.Length < 4) break;
- int length = ReadBigEndianInt(lengthBytes);
+ case "IHDR": ihdrData = data; break;
+ case "tRNS": trnsData = data; break;
- var chunkType = Encoding.ASCII.GetString(br.ReadBytes(4));
+ case "acTL":
+ numPlays = ReadBE32(data, 4);
+ break;
- if (chunkType == "acTL")
- {
- return true;
- }
+ case "fcTL":
+ if (cur != null && idatBufs.Count > 0)
+ {
+ cur.CompressedData = MergeByteArrays(idatBufs);
+ frames.Add(cur);
+ idatBufs = new List();
+ }
+ cur = ParseFctl(data);
+ break;
+
+ case "IDAT":
+ if (cur != null) idatBufs.Add(data);
+ break;
- pngStream.Seek(length + 4, SeekOrigin.Current);
+ case "fdAT":
+ if (cur != null && data.Length > 4)
+ {
+ var payload = new byte[data.Length - 4];
+ Buffer.BlockCopy(data, 4, payload, 0, payload.Length);
+ idatBufs.Add(payload);
+ }
+ break;
- if (chunkType == "IEND")
- break;
+ case "IEND":
+ if (cur != null && idatBufs.Count > 0)
+ {
+ cur.CompressedData = MergeByteArrays(idatBufs);
+ frames.Add(cur);
+ }
+ goto doneReading;
}
}
- return false;
+ doneReading:
+
+ if (ihdrData == null || frames.Count == 0)
+ throw new Exception("No frames found.");
+
+ int canvasW = ReadBE32(ihdrData, 0);
+ int canvasH = ReadBE32(ihdrData, 4);
+ byte colorType = ihdrData[9];
+
+ (byte R, byte G, byte B)? transparentRgb = null;
+ if (trnsData != null && colorType == 2 && trnsData.Length >= 6)
+ transparentRgb = (trnsData[1], trnsData[3], trnsData[5]);
+
+ return new ApngFile
+ {
+ CanvasWidth = canvasW,
+ CanvasHeight = canvasH,
+ NumPlays = numPlays,
+ ColorType = colorType,
+ TransparentRgb = transparentRgb,
+ Frames = frames
+ };
}
- finally
+ catch (Exception ex)
{
- pngStream.Seek(originalPos, SeekOrigin.Begin);
+ Log.Add(Log.LogSeverity.Error, "APNGHelper.ReadApng", ex);
+ return null;
}
}
+ // Frame decode
+
+ private static byte[] DecodeFrame(
+ ApngRawFrame raw,
+ byte colorType,
+ (byte R, byte G, byte B)? transparentRgb)
+ {
+ // Decompress zlib payload (skip 2-byte header: CMF + FLG)
+ byte[] pixels;
+ using (var ms = new MemoryStream(raw.CompressedData, 2, raw.CompressedData.Length - 2))
+ using (var ds = new DeflateStream(ms, CompressionMode.Decompress))
+ using (var buf = new MemoryStream())
+ {
+ ds.CopyTo(buf);
+ pixels = buf.ToArray();
+ }
+
+ int w = raw.Width;
+ int h = raw.Height;
+ int bpp = colorType switch { 0 => 1, 2 => 3, 4 => 2, 6 => 4, _ => 3 };
- // Helpers
+ var rgba = new byte[w * h * 4];
+ var prevRow = new byte[w * bpp];
+ int stride = w * bpp + 1; // +1 for filter byte per row
- private static bool BytesEqual(byte[] a, byte[] b)
+ for (int y = 0; y < h; y++)
+ {
+ byte filter = pixels[y * stride];
+ var row = new byte[w * bpp];
+ Buffer.BlockCopy(pixels, y * stride + 1, row, 0, row.Length);
+ ApplyPngFilter(filter, row, prevRow, bpp);
+
+ for (int x = 0; x < w; x++)
+ {
+ int src = x * bpp;
+ int dst = (y * w + x) * 4;
+ byte r, g, b, a;
+
+ switch (colorType)
+ {
+ case 2: // RGB
+ r = row[src]; g = row[src + 1]; b = row[src + 2];
+ a = transparentRgb.HasValue
+ && r == transparentRgb.Value.R
+ && g == transparentRgb.Value.G
+ && b == transparentRgb.Value.B
+ ? (byte)0 : (byte)255;
+ break;
+ case 6: // RGBA
+ r = row[src]; g = row[src+1]; b = row[src+2]; a = row[src+3];
+ break;
+ case 4: // Grayscale + alpha
+ r = g = b = row[src]; a = row[src + 1];
+ break;
+ default: // Grayscale
+ r = g = b = row[src]; a = 255;
+ break;
+ }
+
+ rgba[dst] = r; rgba[dst+1] = g; rgba[dst+2] = b; rgba[dst+3] = a;
+ }
+
+ Buffer.BlockCopy(row, 0, prevRow, 0, row.Length);
+ }
+
+ return rgba;
+ }
+
+ private static void ApplyPngFilter(byte filter, byte[] row, byte[] prev, int bpp)
{
- if (a.Length != b.Length) return false;
- for (int i = 0; i < a.Length; i++)
- if (a[i] != b[i]) return false;
- return true;
+ switch (filter)
+ {
+ case 1: // Sub
+ for (int i = bpp; i < row.Length; i++)
+ row[i] = (byte)(row[i] + row[i - bpp]);
+ break;
+ case 2: // Up
+ for (int i = 0; i < row.Length; i++)
+ row[i] = (byte)(row[i] + prev[i]);
+ break;
+ case 3: // Average
+ for (int i = 0; i < row.Length; i++)
+ {
+ byte left = i >= bpp ? row[i - bpp] : (byte)0;
+ row[i] = (byte)(row[i] + ((left + prev[i]) >> 1));
+ }
+ break;
+ case 4: // Paeth
+ for (int i = 0; i < row.Length; i++)
+ {
+ byte left = i >= bpp ? row[i - bpp] : (byte)0;
+ byte upLeft = i >= bpp ? prev[i - bpp] : (byte)0;
+ row[i] = (byte)(row[i] + PaethPredictor(left, prev[i], upLeft));
+ }
+ break;
+ // case 0 None: no-op
+ }
+ }
+
+ private static byte PaethPredictor(byte a, byte b, byte c)
+ {
+ int p = a + b - c;
+ int pa = Math.Abs(p - a), pb = Math.Abs(p - b), pc = Math.Abs(p - c);
+ return pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
+ }
+
+ // Canvas ops
+
+ private static void CompositeFrame(
+ byte[] canvas, int canvasW, byte[] frameRgba, ApngRawFrame raw)
+ {
+ int fw = raw.Width, fh = raw.Height, ox = raw.X, oy = raw.Y;
+
+ for (int y = 0; y < fh; y++)
+ {
+ int rowSrc = y * fw * 4;
+ int rowDst = (oy + y) * canvasW * 4 + ox * 4;
+
+ if (raw.BlendOp == BlendOpSource)
+ {
+ // Fast path: direct replace, one copy per row
+ Buffer.BlockCopy(frameRgba, rowSrc, canvas, rowDst, fw * 4);
+ }
+ else
+ {
+ // APNG_BLEND_OP_OVER: alpha composite per pixel
+ for (int x = 0; x < fw; x++)
+ {
+ int fi = rowSrc + x * 4;
+ int ci = rowDst + x * 4;
+ byte sA = frameRgba[fi + 3];
+
+ if (sA == 0) continue; // fully transparent: skip
+ byte sR = frameRgba[fi], sG = frameRgba[fi+1], sB = frameRgba[fi+2];
+
+ if (sA == 255) // fully opaque: fast replace
+ {
+ canvas[ci] = sR; canvas[ci+1] = sG;
+ canvas[ci+2] = sB; canvas[ci+3] = 255;
+ }
+ else // partial: blend
+ {
+ float sa = sA / 255f;
+ float da = canvas[ci+3] / 255f;
+ float oa = sa + da * (1f - sa);
+ if (oa > 0f)
+ {
+ float dsa = da * (1f - sa);
+ float inv = 1f / oa;
+ canvas[ci] = (byte)((sR * sa + canvas[ci] * dsa) * inv);
+ canvas[ci+1] = (byte)((sG * sa + canvas[ci+1] * dsa) * inv);
+ canvas[ci+2] = (byte)((sB * sa + canvas[ci+2] * dsa) * inv);
+ canvas[ci+3] = (byte)(oa * 255f);
+ }
+ }
+ }
+ }
+ }
+ }
+
+ private static void ClearRegion(byte[] canvas, int canvasW, int x, int y, int w, int h)
+ {
+ for (int row = 0; row < h; row++)
+ Array.Clear(canvas, ((y + row) * canvasW + x) * 4, w * 4);
+ }
+
+ private static WriteableBitmap CanvasToWriteableBitmap(byte[] canvas, int w, int h)
+ {
+ var bmp = new WriteableBitmap(
+ new PixelSize(w, h),
+ new Vector(96, 96),
+ PixelFormat.Rgba8888,
+ AlphaFormat.Unpremul);
+ using var fb = bmp.Lock();
+ Marshal.Copy(canvas, 0, fb.Address, canvas.Length);
+ return bmp;
+ }
+
+ // Chunk helpers
+
+ private static ApngRawFrame ParseFctl(byte[] d) => new ApngRawFrame
+ {
+ // layout: seq(4) w(4) h(4) x(4) y(4) delayNum(2) delayDen(2) disposeOp(1) blendOp(1)
+ Width = ReadBE32(d, 4),
+ Height = ReadBE32(d, 8),
+ X = ReadBE32(d, 12),
+ Y = ReadBE32(d, 16),
+ DelayNum = ReadBE16(d, 20),
+ DelayDen = ReadBE16(d, 22),
+ DisposeOp = d[24],
+ BlendOp = d[25]
+ };
+
+ private static byte[] MergeByteArrays(List blocks)
+ {
+ int total = 0;
+ foreach (var b in blocks) total += b.Length;
+ var merged = new byte[total];
+ int offset = 0;
+ foreach (var b in blocks)
+ {
+ Buffer.BlockCopy(b, 0, merged, offset, b.Length);
+ offset += b.Length;
+ }
+ return merged;
+ }
+
+ private static string ReadChunkType(BinaryReader br) =>
+ Encoding.ASCII.GetString(br.ReadBytes(4));
+
+ private static int ReadBE32(BinaryReader br)
+ {
+ var b = br.ReadBytes(4);
+ if (BitConverter.IsLittleEndian) Array.Reverse(b);
+ return BitConverter.ToInt32(b, 0);
+ }
+
+ private static int ReadBE32(byte[] b, int offset = 0)
+ {
+ byte[] tmp = new byte[4];
+ Buffer.BlockCopy(b, offset, tmp, 0, 4);
+ if (BitConverter.IsLittleEndian) Array.Reverse(tmp);
+ return BitConverter.ToInt32(tmp, 0);
+ }
+
+ private static int ReadBE16(byte[] b, int offset = 0)
+ {
+ byte[] tmp = new byte[2];
+ Buffer.BlockCopy(b, offset, tmp, 0, 2);
+ if (BitConverter.IsLittleEndian) Array.Reverse(tmp);
+ return (int)BitConverter.ToUInt16(tmp, 0);
}
- private static int ReadBigEndianInt(byte[] bytes)
+ private static bool BytesEqual(byte[] a, byte[] b)
{
- if (BitConverter.IsLittleEndian) Array.Reverse(bytes);
- return BitConverter.ToInt32(bytes, 0);
+ if (a.Length != b.Length) return false;
+ for (int i = 0; i < a.Length; i++) if (a[i] != b[i]) return false;
+ return true;
}
}
}
diff --git a/Knossos.NET/Controls/AnimatedImage.cs b/Knossos.NET/Controls/AnimatedImage.cs
new file mode 100644
index 00000000..f592f516
--- /dev/null
+++ b/Knossos.NET/Controls/AnimatedImage.cs
@@ -0,0 +1,398 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Media.Imaging;
+using Avalonia.Platform;
+using Avalonia.Threading;
+using Knossos.NET.Classes;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using SystemPath = System.IO.Path;
+
+namespace Knossos.NET.Controls
+{
+ ///
+ /// Image control that auto-detects what its been given and animates accordingly:
+ ///
+ /// - Path is an APNG (.png/.apng) -> APNG animation
+ /// - Path is any other supported image -> static image (jpg, bmp, webp…)
+ /// - Stream is set -> Loads any static image or animation from a stream,
+ /// the Extension property must also be set
+ /// - Frames is set -> manual slideshow, bitmap list (overrides Path and Stream)
+ /// - FrameDelays -> list of delay for each frame for the slideshow
+ ///
+ /// "Path" accepts either a regular file path or an "avares://" URI.
+ /// Animation is driven by a single DispatcherTimer — when Playing=false the timer
+ /// is stopped and no CPU is used until Playing flips back to true.
+ ///
+ /// For APNG/ANI a single WriteableBitmap is allocated once and re-written every frame.
+ ///
+ /// Usage in XAML:
+ /// xmlns:c="using:VP.NET.GUI.Controls"
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ public class AnimatedImage : Image
+ {
+ // Styled properties
+
+ public static readonly StyledProperty PathProperty =
+ AvaloniaProperty.Register(nameof(Path));
+
+ public static readonly StyledProperty StreamProperty =
+ AvaloniaProperty.Register(nameof(Stream));
+
+ public static readonly StyledProperty ExtensionProperty =
+ AvaloniaProperty.Register(nameof(Extension));
+
+ public static readonly StyledProperty PlayingProperty =
+ AvaloniaProperty.Register(nameof(Playing), defaultValue: true);
+
+ public static readonly StyledProperty?> FramesProperty =
+ AvaloniaProperty.Register?>(nameof(Frames));
+
+ public static readonly StyledProperty?> FrameDelaysProperty =
+ AvaloniaProperty.Register?>(nameof(FrameDelays));
+
+ public static readonly StyledProperty DefaultFrameDelayProperty =
+ AvaloniaProperty.Register(nameof(DefaultFrameDelay), defaultValue: 100);
+
+ /// File path or avares:// URI of the image to display.
+ public string? Path
+ {
+ get => GetValue(PathProperty);
+ set => SetValue(PathProperty, value);
+ }
+
+ /// Any stream, an extension must be provided in the Extension property. The stream will not be disposed.
+ public Stream? Stream
+ {
+ get => GetValue(StreamProperty);
+ set => SetValue(StreamProperty, value);
+ }
+
+ /// File extension (only used with stream).
+ public string? Extension
+ {
+ get => GetValue(ExtensionProperty);
+ set => SetValue(ExtensionProperty, value);
+ }
+
+ /// True to play the animation, false to pause it. Has no effect on static images.
+ public bool Playing
+ {
+ get => GetValue(PlayingProperty);
+ set => SetValue(PlayingProperty, value);
+ }
+
+ /// Optional manual frame collection. Takes priority over Path or Stream when set.
+ public IList? Frames
+ {
+ get => GetValue(FramesProperty);
+ set => SetValue(FramesProperty, value);
+ }
+
+ /// Optional per-frame delays in milliseconds for manual mode. If null or shorter than Frames, DefaultFrameDelay is used.
+ public IList? FrameDelays
+ {
+ get => GetValue(FrameDelaysProperty);
+ set => SetValue(FrameDelaysProperty, value);
+ }
+
+ /// Fallback delay in milliseconds for manual mode when FrameDelays is null or short.
+ public int DefaultFrameDelay
+ {
+ get => GetValue(DefaultFrameDelayProperty);
+ set => SetValue(DefaultFrameDelayProperty, value);
+ }
+
+ // ─────────── Internal state ───────────
+
+ private enum Mode { None, Static, Apng, Ani, Manual }
+
+ private Mode _mode = Mode.None;
+ private DispatcherTimer? _timer;
+
+ private APNGHelper.ApngFile? _apngFile;
+ private APNGHelper.ApngComposer? _apngComposer;
+ private int _manualIndex;
+ private int _lastDelayMs = 100;
+ private WriteableBitmap? _reusableBitmap;
+
+ // Lifecycle
+
+ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
+ {
+ base.OnPropertyChanged(change);
+
+ if (change.Property == PathProperty)
+ {
+ ReloadFromPath();
+ }
+ else if (change.Property == StreamProperty)
+ {
+ ReloadFromStream();
+ }
+ else if (change.Property == FramesProperty)
+ {
+ ReloadManual();
+ }
+ else if (change.Property == PlayingProperty)
+ {
+ if (Playing) StartTimer();
+ else StopTimer();
+ }
+ }
+
+ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnAttachedToVisualTree(e);
+ // If we already have content and we are meant to play, (re)start the timer.
+ if (Playing) StartTimer();
+ }
+
+ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ // Stop the timer so we dont fire ticks while invisible.
+ // We deliberately keep the decoded state around so reattaching is cheap;
+ // set Path = null (or Frames = null) to release the bitmap buffers.
+ StopTimer();
+ base.OnDetachedFromVisualTree(e);
+ }
+
+ // Loading
+
+ private void DisposeAnimatedState()
+ {
+ StopTimer();
+ _apngComposer?.Dispose();
+ _apngComposer = null;
+ _apngFile = null;
+ _reusableBitmap?.Dispose();
+ _reusableBitmap = null;
+ _manualIndex = 0;
+ _lastDelayMs = 100;
+ _mode = Mode.None;
+ }
+
+ private void ReloadManual()
+ {
+ var frames = Frames;
+ if (frames != null && frames.Count > 0)
+ {
+ DisposeAnimatedState();
+ _mode = Mode.Manual;
+ _manualIndex = 0;
+ _lastDelayMs = GetManualDelay(0);
+ Source = frames[0];
+
+ if (frames.Count > 1 && Playing) StartTimer();
+ return;
+ }
+
+ // Frames became null/empty: fall back to whatever Path says (if anything).
+ DisposeAnimatedState();
+ Source = null;
+ ReloadFromPath();
+ }
+
+ private void ReloadFromStream()
+ {
+ if (Frames != null && Frames.Count > 0) return;
+
+ DisposeAnimatedState();
+ Source = null;
+
+ string? ext = Extension;
+ Stream? stream = Stream;
+ if (stream == null || string.IsNullOrWhiteSpace(ext)) return;
+
+ try
+ {
+ switch (ext)
+ {
+ case "png":
+ case "apng":
+ if (TryIsApng(stream))
+ LoadApng(stream);
+ else
+ LoadStatic(stream);
+ break;
+
+ default:
+ LoadStatic(stream);
+ break;
+ }
+ }
+ catch (Exception ex)
+ {
+ Log.Add(Log.LogSeverity.Error, "AnimatedImage.ReloadFromStream", ex);
+ }
+ }
+
+ private void ReloadFromPath()
+ {
+ if (Frames != null && Frames.Count > 0) return;
+
+ DisposeAnimatedState();
+ Source = null;
+
+ string? path = Path;
+ if (string.IsNullOrWhiteSpace(path)) return;
+
+ try
+ {
+ string ext = SystemPath.GetExtension(path).ToLowerInvariant();
+ using Stream stream = OpenStream(path);
+
+ switch (ext)
+ {
+ case ".png":
+ case ".apng":
+ if (TryIsApng(stream))
+ LoadApng(stream);
+ else
+ LoadStatic(stream);
+ break;
+
+ default:
+ LoadStatic(stream);
+ break;
+ }
+ }
+ catch (Exception ex)
+ {
+ Log.Add(Log.LogSeverity.Error, "AnimatedImage.ReloadFromPath", ex);
+ }
+ }
+
+ private static Stream OpenStream(string path)
+ {
+ Stream raw;
+ if (path.StartsWith("avares://", StringComparison.OrdinalIgnoreCase) ||
+ path.StartsWith("resm:", StringComparison.OrdinalIgnoreCase))
+ {
+ raw = AssetLoader.Open(new Uri(path));
+ }
+ else
+ {
+ raw = File.OpenRead(path);
+ }
+
+ // The helpers all do stream.Seek(0, ...), so make sure we hand them
+ // a seekable stream regardless of where it came from.
+ if (raw.CanSeek) return raw;
+
+ var ms = new MemoryStream();
+ using (raw) raw.CopyTo(ms);
+ ms.Position = 0;
+ return ms;
+ }
+
+ private static bool TryIsApng(Stream stream)
+ {
+ try { return APNGHelper.IsApng(stream); }
+ catch { return false; }
+ }
+
+ private void LoadStatic(Stream stream)
+ {
+ stream.Seek(0, SeekOrigin.Begin);
+ Source = new Bitmap(stream);
+ _mode = Mode.Static;
+ }
+
+ private void LoadApng(Stream stream)
+ {
+ stream.Seek(0, SeekOrigin.Begin);
+ var apng = APNGHelper.ReadApng(stream);
+ if (apng == null || apng.Frames.Count == 0)
+ {
+ Log.Add(Log.LogSeverity.Error, "AnimatedImage.LoadApng", "Failed to parse APNG.");
+ return;
+ }
+
+ _apngFile = apng;
+ _apngComposer = apng.CreateComposer();
+ _reusableBitmap = _apngComposer.CreateMatchingBitmap();
+ Source = _reusableBitmap;
+ _mode = Mode.Apng;
+
+ // Render the first frame immediately so we always show something,
+ // even when Playing=false from the start.
+ _apngComposer.RenderNextFrameTo(_reusableBitmap, out _lastDelayMs);
+ InvalidateVisual();
+
+ if (apng.Frames.Count > 1 && Playing) StartTimer();
+ }
+
+ // Animation loop
+
+ private void StartTimer()
+ {
+ if (_mode != Mode.Apng && _mode != Mode.Ani && _mode != Mode.Manual) return;
+
+ _timer ??= new DispatcherTimer();
+ _timer.Tick -= OnTick;
+ _timer.Tick += OnTick;
+ _timer.Interval = TimeSpan.FromMilliseconds(Math.Max(1, _lastDelayMs));
+ _timer.Start();
+ }
+
+ private void StopTimer()
+ {
+ if (_timer == null) return;
+ _timer.Stop();
+ _timer.Tick -= OnTick;
+ }
+
+ private void OnTick(object? sender, EventArgs e)
+ {
+ try
+ {
+ switch (_mode)
+ {
+ case Mode.Apng:
+ if (_apngComposer != null && _reusableBitmap != null)
+ {
+ _apngComposer.RenderNextFrameTo(_reusableBitmap, out _lastDelayMs);
+ InvalidateVisual();
+ }
+ break;
+
+ case Mode.Manual:
+ var frames = Frames;
+ if (frames != null && frames.Count > 0)
+ {
+ _manualIndex = (_manualIndex + 1) % frames.Count;
+ Source = frames[_manualIndex];
+ _lastDelayMs = GetManualDelay(_manualIndex);
+ }
+ break;
+ }
+ }
+ catch (Exception ex)
+ {
+ Log.Add(Log.LogSeverity.Error, "AnimatedImage.OnTick", ex);
+ StopTimer();
+ return;
+ }
+
+ if (_timer != null)
+ _timer.Interval = TimeSpan.FromMilliseconds(Math.Max(1, _lastDelayMs));
+ }
+
+ private int GetManualDelay(int index)
+ {
+ var delays = FrameDelays;
+ if (delays != null && index >= 0 && index < delays.Count)
+ return Math.Max(1, delays[index]);
+ return Math.Max(1, DefaultFrameDelay);
+ }
+ }
+}
diff --git a/Knossos.NET/Converters/RemotePathAssetValueConverter.cs b/Knossos.NET/Converters/RemotePathAssetValueConverter.cs
new file mode 100644
index 00000000..1c1447aa
--- /dev/null
+++ b/Knossos.NET/Converters/RemotePathAssetValueConverter.cs
@@ -0,0 +1,69 @@
+using Avalonia.Data.Converters;
+using System;
+using System.Globalization;
+using Avalonia.Platform;
+using Avalonia.Media.Imaging;
+using System.IO;
+using System.Threading.Tasks;
+
+namespace Knossos.NET.Converters
+{
+ public class RemotePathAssetValueConverter : IValueConverter
+ {
+ public static RemotePathAssetValueConverter Instance { get; } = new();
+
+ public object? Convert(object? value, Type targetType, object? parameter, CultureInfo? culture)
+ {
+ try
+ {
+ if (value == null) return null;
+
+ if(!targetType.IsAssignableFrom(typeof(string)))
+ throw new NotSupportedException();
+
+ if (value is not string rawUri)
+ {
+ if(parameter is string)
+ {
+ rawUri = (string)parameter;
+ }
+ else
+ {
+ throw new NotSupportedException();
+ }
+ }
+
+ if (rawUri.StartsWith("avares://"))
+ {
+ return rawUri;
+ }
+ else if (rawUri.ToLower().StartsWith("http"))
+ {
+ var localPath = Task.Run(() => KnUtils.GetRemoteResource(rawUri)).Result;
+ if (localPath != null)
+ {
+ return localPath;
+ }
+ }
+ else if (File.Exists(Path.Combine(KnUtils.GetKnossosDataFolderPath(), rawUri)))
+ {
+ return Path.Combine(KnUtils.GetKnossosDataFolderPath(), rawUri);
+ }
+ else if (File.Exists(rawUri))
+ {
+ return rawUri;
+ }
+ }
+ catch (Exception ex)
+ {
+ Log.Add(Log.LogSeverity.Error, "RemotePathAssetValueConverter.Convert()", ex);
+ }
+ return null;
+ }
+
+ public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ throw new NotImplementedException();
+ }
+ }
+}
diff --git a/Knossos.NET/Knossos.NET.csproj b/Knossos.NET/Knossos.NET.csproj
index 31e9d4ea..ed803e1c 100644
--- a/Knossos.NET/Knossos.NET.csproj
+++ b/Knossos.NET/Knossos.NET.csproj
@@ -58,7 +58,6 @@
-
diff --git a/Knossos.NET/ViewModels/ModListViewModel.cs b/Knossos.NET/ViewModels/ModListViewModel.cs
index 914f51eb..217c835a 100644
--- a/Knossos.NET/ViewModels/ModListViewModel.cs
+++ b/Knossos.NET/ViewModels/ModListViewModel.cs
@@ -293,7 +293,7 @@ public void AddMod(Mod modJson)
internal void ChangeSort(object sort)
{
Sorting = true;
- LoadingAnimation.Animate = 1;
+ LoadingAnimation.Animate = true;
var newSort = ModSortType.unsorted;
if (sort is ModSortType)
{
@@ -310,7 +310,7 @@ internal void ChangeSort(object sort)
Mods.Sort(); //It will use ModCardViewModel.CompareTo()
}
SortString = "Sorted by " + newSort;
- LoadingAnimation.Animate = 0;
+ LoadingAnimation.Animate = false;
Sorting = false;
}
diff --git a/Knossos.NET/ViewModels/NebulaModListViewModel.cs b/Knossos.NET/ViewModels/NebulaModListViewModel.cs
index 167bf07c..a1074ec2 100644
--- a/Knossos.NET/ViewModels/NebulaModListViewModel.cs
+++ b/Knossos.NET/ViewModels/NebulaModListViewModel.cs
@@ -30,11 +30,11 @@ internal bool isLoading{
ShowTiles = !isLoading;
if (ShowTiles)
{
- LoadingAnimation.Animate = 0;
+ LoadingAnimation.Animate = false;
}
else
{
- LoadingAnimation.Animate = 1;
+ LoadingAnimation.Animate = true;
}
}
}
@@ -82,7 +82,7 @@ internal string Search
public NebulaModListViewModel()
{
- LoadingAnimation.Animate = 1;
+ LoadingAnimation.Animate = true;
CardsView = Mods.ToNotifyCollectionChangedSlim(SynchronizationContextCollectionEventDispatcher.Current);
}
@@ -211,7 +211,7 @@ public void OpenTab()
Task.Run(() =>
{
ShowTiles = false;
- LoadingAnimation.Animate = 1;
+ LoadingAnimation.Animate = true;
if (MainViewModel.Instance != null)
{
if (Search != MainViewModel.Instance.sharedSearch)
@@ -231,7 +231,7 @@ public void OpenTab()
{
await card.LoadImage();
});
- LoadingAnimation.Animate = 0;
+ LoadingAnimation.Animate = false;
ShowTiles = true;
});
}
diff --git a/Knossos.NET/ViewModels/Templates/LoadingIconViewModel.cs b/Knossos.NET/ViewModels/Templates/LoadingIconViewModel.cs
index 9cba2170..2f432a0a 100644
--- a/Knossos.NET/ViewModels/Templates/LoadingIconViewModel.cs
+++ b/Knossos.NET/ViewModels/Templates/LoadingIconViewModel.cs
@@ -7,6 +7,7 @@ namespace Knossos.NET.ViewModels
{
public partial class LoadingIconViewModel : ViewModelBase
{
- public int Animate {get; set;} = 0;
+ [ObservableProperty]
+ public bool animate = false;
}
}
diff --git a/Knossos.NET/ViewModels/Templates/TaskInfoButtonViewModel.cs b/Knossos.NET/ViewModels/Templates/TaskInfoButtonViewModel.cs
index d348c784..a8c89000 100644
--- a/Knossos.NET/ViewModels/Templates/TaskInfoButtonViewModel.cs
+++ b/Knossos.NET/ViewModels/Templates/TaskInfoButtonViewModel.cs
@@ -1,7 +1,4 @@
using CommunityToolkit.Mvvm.ComponentModel;
-using System;
-using System.Threading;
-using System.Threading.Tasks;
namespace Knossos.NET.ViewModels
{
@@ -11,13 +8,9 @@ public partial class TaskInfoButtonViewModel : ViewModelBase
[ObservableProperty]
internal int taskNumber = 0;
[ObservableProperty]
- internal int animate = 0;
- [ObservableProperty]
internal string tooltip = "";
[ObservableProperty]
- internal bool frame0 = true;
- [ObservableProperty]
- internal bool frame1 = false;
+ internal bool animate = true;
public TaskInfoButtonViewModel()
{
@@ -46,17 +39,17 @@ private void Update(object? _, System.Timers.ElapsedEventArgs __)
Tooltip = "Open Task List\n\n" + TaskViewModel.GetRunningTaskString();
if (!TaskViewModel.IsSafeState())
{
- Animate = 1;
+ Animate = true;
}
else
{
- Animate = 0;
+ Animate = false;
}
}
else
{
Tooltip = "Open Task List";
- Animate = 0;
+ Animate = false;
}
}
}
diff --git a/Knossos.NET/ViewModels/Windows/ModDetailsViewModel.cs b/Knossos.NET/ViewModels/Windows/ModDetailsViewModel.cs
index d10e16de..27af0a09 100644
--- a/Knossos.NET/ViewModels/Windows/ModDetailsViewModel.cs
+++ b/Knossos.NET/ViewModels/Windows/ModDetailsViewModel.cs
@@ -86,8 +86,6 @@ public partial class ModDetailsViewModel : ViewModelBase
[ObservableProperty]
internal string? banner = null;
[ObservableProperty]
- internal string? apngBanner = null;
- [ObservableProperty]
internal bool forumAvailable = false;
[ObservableProperty]
internal bool isInstalled = false;
@@ -286,26 +284,9 @@ private void LoadBanner(int selectedIndex)
var bannerLocalPath = modVersions[selectedIndex].fullPath + Path.DirectorySeparatorChar + modVersions[selectedIndex].banner;
if (System.IO.File.Exists(bannerLocalPath))
{
- var isApng = false;
- using (var stream = new FileStream(bannerLocalPath, FileMode.Open, FileAccess.Read))
- {
- try
- {
- isApng = APNGHelper.IsApng(stream);
-
- }
- catch { /* Not a valid png*/ }
- }
Dispatcher.UIThread.Invoke(() =>
{
- if (isApng)
- {
- ApngBanner = bannerLocalPath;
- }
- else
- {
- Banner = bannerLocalPath;
- }
+ Banner = bannerLocalPath;
});
}
else
@@ -318,26 +299,9 @@ private void LoadBanner(int selectedIndex)
var fs = await KnUtils.GetRemoteResource(url).ConfigureAwait(false);
if (fs != null)
{
- var isApng = false;
- using (var stream = new FileStream(fs, FileMode.Open, FileAccess.Read))
- {
- try
- {
- isApng = APNGHelper.IsApng(stream);
-
- }
- catch { /* Not a valid png*/ }
- }
Dispatcher.UIThread.Invoke(() =>
{
- if (isApng)
- {
- ApngBanner = fs;
- }
- else
- {
- Banner = fs;
- }
+ Banner = fs;
});
}
}).ConfigureAwait(false);
diff --git a/Knossos.NET/Views/CustomHomeView.axaml b/Knossos.NET/Views/CustomHomeView.axaml
index b98ef0e5..cff545e7 100644
--- a/Knossos.NET/Views/CustomHomeView.axaml
+++ b/Knossos.NET/Views/CustomHomeView.axaml
@@ -6,8 +6,8 @@
x:Class="Knossos.NET.Views.CustomHomeView"
xmlns:v="using:Knossos.NET.Views"
xmlns:vm="using:Knossos.NET.ViewModels"
- xmlns:anim="https://github.com/whistyun/AnimatedImage.Avalonia"
x:DataType="vm:CustomHomeViewModel"
+ xmlns:c="using:Knossos.NET.Controls"
Name="CustomTCView"
Background="{StaticResource BackgroundColorPrimary}"
xmlns:HtmlRenderer="clr-namespace:TheArtOfDev.HtmlRenderer.Avalonia;assembly=Avalonia.HtmlRenderer"
@@ -17,16 +17,14 @@
-
+
-
+
+
diff --git a/Knossos.NET/Views/Templates/LoadingIconView.axaml b/Knossos.NET/Views/Templates/LoadingIconView.axaml
index af5fb6f9..4933695d 100644
--- a/Knossos.NET/Views/Templates/LoadingIconView.axaml
+++ b/Knossos.NET/Views/Templates/LoadingIconView.axaml
@@ -6,11 +6,11 @@
xmlns:anim="https://github.com/whistyun/AnimatedImage.Avalonia"
x:Class="Knossos.NET.Views.LoadingIconView"
xmlns:v="using:Knossos.NET.Views"
+ xmlns:c="using:Knossos.NET.Controls"
xmlns:vm="using:Knossos.NET.ViewModels"
x:DataType="vm:LoadingIconViewModel">
-
-
+
diff --git a/Knossos.NET/Views/Templates/TaskInfoButtonView.axaml b/Knossos.NET/Views/Templates/TaskInfoButtonView.axaml
index 0ad27ccc..f1fe9b6c 100644
--- a/Knossos.NET/Views/Templates/TaskInfoButtonView.axaml
+++ b/Knossos.NET/Views/Templates/TaskInfoButtonView.axaml
@@ -3,16 +3,15 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="40" d:DesignHeight="40"
- xmlns:anim="https://github.com/whistyun/AnimatedImage.Avalonia"
x:Class="Knossos.NET.Views.TaskInfoButtonView"
xmlns:v="using:Knossos.NET.Views"
+ xmlns:c="using:Knossos.NET.Controls"
xmlns:vm="using:Knossos.NET.ViewModels"
x:DataType="vm:TaskInfoButtonViewModel">
-
-
+
diff --git a/Knossos.NET/Views/Windows/ModDetailsView.axaml b/Knossos.NET/Views/Windows/ModDetailsView.axaml
index 9c86f9a9..f5e2453f 100644
--- a/Knossos.NET/Views/Windows/ModDetailsView.axaml
+++ b/Knossos.NET/Views/Windows/ModDetailsView.axaml
@@ -10,12 +10,12 @@
xmlns:HtmlRenderer="clr-namespace:TheArtOfDev.HtmlRenderer.Avalonia;assembly=Avalonia.HtmlRenderer"
Icon="/Assets/knossos-icon.ico"
WindowStartupLocation="CenterOwner"
+ xmlns:c="using:Knossos.NET.Controls"
MinHeight="400"
MinWidth="400"
WindowWidth="1070"
Title="{Binding Name}"
CanResize="True"
- xmlns:anim="https://github.com/whistyun/AnimatedImage.Avalonia"
xmlns:cvt="clr-namespace:Knossos.NET.Converters;assembly=Knossos.NET">
@@ -23,13 +23,13 @@
-
+
-
+
@@ -98,7 +98,7 @@