diff --git a/LightlessSync/Changelog/changelog.yaml b/LightlessSync/Changelog/changelog.yaml new file mode 100644 index 0000000..2cdbf6a --- /dev/null +++ b/LightlessSync/Changelog/changelog.yaml @@ -0,0 +1,167 @@ +tagline: "Lightless Sync v1.12.3" +subline: "FILLER" +changelog: + - name: "v1.12.3" + tagline: "FILLER" + date: "October 15th 2025" + # be sure to set this every new version + isCurrent: true + versions: + - number: "New Features" + icon: "" + items: + - "New in-game Patch Notes window." + - "Credits section to thank contributors and supporters." + - "Patch notes only show after updates, not during first-time setup." + - number: "Notifications" + icon: "" + items: + - "More customizable notification options." + - "Perfomance limiter shows as notifications." + - "All notifications can be configured or disabled in Settings → Notifications." + + - name: "v1.12.2" + tagline: "LightFinder fixes, Notifications overhaul" + date: "October 12th 2025" + versions: + - number: "LightFinder" + icon: "" + items: + - "Server-side improvements for LightFinder functionality." + - "Command changed from '/light lightfinder' to '/light finder'." + - "Option to enable LightFinder on connection (opt-in, refreshes every 3 hours)." + - "LightFinder indicator can now be shown on the server info bar." + - number: "Notifications" + icon: "" + items: + - "Completely reworked notification system with new UI." + - "Pair requests now show as notifications." + - "Download progress shows as notifications." + - "Customizable notification sounds, size, position, and duration." + - "All notifications can be configured or disabled in Settings → Notifications." + - number: "Bug Fixes" + icon: "" + items: + - "Fixed nameplate alignment issues with LightFinder and icons." + - "Icons now properly apply instead of swapping on choice." + - "Updated Discord URL." + - "File cache logic improvements." + + - name: "v1.12.1" + tagline: "LightFinder customization and download limiter" + date: "October 8th 2025" + versions: + - number: "New Features" + icon: "" + items: + - "LightFinder text can be modified to an icon with customizable positioning." + - "Option to hide your own indicator or paired player indicators." + - "Pair Download Limiter: Limit simultaneous downloads to 1-6 users to reduce network strain." + - "Added '/light lightfinder' command to open LightFinder UI." + - number: "Improvements" + icon: "" + items: + - "Right-click menu option for Send Pair Request can be disabled." + - "Syncshell finder improvements." + - "Download limiter settings available in Settings → Transfers." + + - name: "v1.12.0" + tagline: "LightFinder - Major feature release" + date: "October 5th 2025" + versions: + - number: "Major Features" + icon: "" + items: + - "Introduced LightFinder: Optional feature inspired by FFXIV's Party Finder." + - "Find fellow Lightless users and advertise your Syncshell to others." + - "When enabled, you're visible to other LightFinder users for 3 hours." + - "LightFinder tag displays above your nameplate when active." + - "Receive pair requests directly in UI without exchanging UIDs." + - "Syncshell Finder allows joining indexed Syncshells." + - "[L] Send Pair Request added to player context menus." + - number: "Vanity Features" + icon: "" + items: + - "Supporters can now customize their name color in the Lightless UI." + - "Color changes visible to all users." + - number: "General Improvements" + icon: "" + items: + - "Pairing nameplate color override can now override FC tags." + - "Added .kdb as whitelisted filetype for uploads." + - "Various UI fixes, updates, and improvements." + + - name: "v1.11.12" + tagline: "Syncshell grouping and performance options" + date: "September 16th 2025" + versions: + - number: "New Features" + icon: "" + items: + - "Ability to show grouped syncshells in main UI/all syncshells (default ON)." + - "Transfer ownership button available in Admin Panel user list." + - "Self-threshold warning now opens character analysis screen when clicked." + - number: "Performance" + icon: "" + items: + - "Auto-pause combat and auto-pause performance are now optional settings." + - "Both options are auto-enabled by default - disable at your own risk." + - number: "Bug Fixes" + icon: "" + items: + - "Reworked file caching to reduce errors for some users." + - "Fixed bug where exiting PvP could desync some users." + + - name: "v1.11.9" + tagline: "File cache improvements" + date: "September 13th 2025" + versions: + - number: "Bug Fixes" + icon: "" + items: + - "Identified and fixed potential file cache problems." + - "Improved cache error handling and stability." + + - name: "v1.11.8" + tagline: "Hotfix - UI and exception handling" + date: "September 12th 2025" + versions: + - number: "Bug Fixes" + icon: "" + items: + - "Attempted fix for NullReferenceException spam." + - "Fixed additional UI edge cases preventing loading for some users." + - "Fixed color bar UI issues." + + - name: "v1.11.7" + tagline: "Hotfix - UI loading and warnings" + date: "September 12th 2025" + versions: + - number: "Bug Fixes" + icon: "" + items: + - "Fixed UI not loading for some users." + - "Self warnings now behind 'Warn on loading in players exceeding performance thresholds' setting." + + - name: "v1.11.6" + tagline: "Admin panel rework and new features" + date: "September 11th 2025" + versions: + - number: "New Features" + icon: "" + items: + - "Reworked Syncshell Admin Page with improved styling." + - "Right-click on Server Top Bar button to disconnect from Lightless." + - "Shift+Left click on Server Top Bar button to open settings." + - "Added colors section in settings to change accent colors." + - "Ability to pause syncing while in Instance/Duty." + - "Functionality to create syncshell folders." + - "Added self-threshold warning." + - number: "Bug Fixes" + icon: "" + items: + - "Fixed owners being visible in moderator list view." + - "Removed Pin/Remove/Ban buttons on Owners when viewing as moderator." + - "Fixed nameplate bug in PvP." + - "Added 1 or 3 day options for inactive check." + - "Fixed bug where some users could not see their own syncshell folders." \ No newline at end of file diff --git a/LightlessSync/Changelog/credits.yaml b/LightlessSync/Changelog/credits.yaml new file mode 100644 index 0000000..b3b3e8c --- /dev/null +++ b/LightlessSync/Changelog/credits.yaml @@ -0,0 +1,35 @@ +credits: + - category: "Development Team" + items: + - name: "Choco" + role: "Cringe Developer" + - name: "Additional Contributors" + role: "Community Contributors & Bug Reporters" + + - category: "Plugin Integration & IPC Support" + items: + - name: "Penumbra Team" + role: "Mod framework integration" + - name: "Glamourer Team" + role: "Customization system integration" + - name: "Customize+ Team" + role: "Body scaling integration" + - name: "Simple Heels Team" + role: "Height offset integration" + - name: "Honorific Team" + role: "Title system integration" + - name: "Moodles Team" + role: "Status effect integration" + - name: "PetNicknames Team" + role: "Pet naming integration" + - name: "Brio Team" + role: "GPose enhancement integration" + + - category: "Special Thanks" + items: + - name: "Dalamud & XIVLauncher Teams" + role: "Plugin framework and infrastructure" + - name: "Community Supporters" + role: "Testing, feedback, and financial support" + - name: "Beta Testers" + role: "Early testing and bug reporting" diff --git a/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs b/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs index 203db7d..f849c87 100644 --- a/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs +++ b/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs @@ -147,4 +147,5 @@ public class LightlessConfig : ILightlessConfiguration public DateTime BroadcastTtl { get; set; } = DateTime.MinValue; public bool SyncshellFinderEnabled { get; set; } = false; public string? SelectedFinderSyncshell { get; set; } = null; + public string LastSeenVersion { get; set; } = string.Empty; } diff --git a/LightlessSync/LightlessPlugin.cs b/LightlessSync/LightlessPlugin.cs index 66d2c2b..fe7e9a4 100644 --- a/LightlessSync/LightlessPlugin.cs +++ b/LightlessSync/LightlessPlugin.cs @@ -9,6 +9,7 @@ using LightlessSync.UI; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using Serilog; using System.Reflection; namespace LightlessSync; @@ -101,7 +102,7 @@ public class LightlessPlugin : MediatorSubscriberBase, IHostedService UIColors.Initialize(_lightlessConfigService); Mediator.StartQueueProcessing(); - + return Task.CompletedTask; } @@ -115,6 +116,24 @@ public class LightlessPlugin : MediatorSubscriberBase, IHostedService return Task.CompletedTask; } + + private void CheckVersion() + { + var ver = Assembly.GetExecutingAssembly().GetName().Version; + var currentVersion = ver == null ? string.Empty : $"{ver.Major}.{ver.Minor}.{ver.Build}"; + var lastSeen = _lightlessConfigService.Current.LastSeenVersion ?? string.Empty; + Logger.LogInformation("Last seen version: {lastSeen}, current version: {currentVersion}", lastSeen, currentVersion); + Logger.LogInformation("User has valid setup: {hasValidSetup}", _lightlessConfigService.Current.HasValidSetup()); + Logger.LogInformation("Server has valid config: {hasValidConfig}", _serverConfigurationManager.HasValidConfig()); + // Show update notes if version has changed and user has valid setup + + if (!string.Equals(lastSeen, currentVersion, StringComparison.Ordinal) && + _lightlessConfigService.Current.HasValidSetup() && + _serverConfigurationManager.HasValidConfig()) + { + Mediator.Publish(new UiToggleMessage(typeof(UpdateNotesUi))); + } + } private void DalamudUtilOnLogIn() { @@ -154,6 +173,7 @@ public class LightlessPlugin : MediatorSubscriberBase, IHostedService _runtimeServiceScope.ServiceProvider.GetRequiredService(); _runtimeServiceScope.ServiceProvider.GetRequiredService(); _runtimeServiceScope.ServiceProvider.GetRequiredService(); + CheckVersion(); #if !DEBUG if (_lightlessConfigService.Current.LogLevel != LogLevel.Information) diff --git a/LightlessSync/LightlessSync.csproj b/LightlessSync/LightlessSync.csproj index 5b31c88..b4b5288 100644 --- a/LightlessSync/LightlessSync.csproj +++ b/LightlessSync/LightlessSync.csproj @@ -1,4 +1,4 @@ - + @@ -46,6 +46,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + @@ -64,6 +65,8 @@ PreserveNewest + + diff --git a/LightlessSync/Plugin.cs b/LightlessSync/Plugin.cs index bac9e29..9ec4bed 100644 --- a/LightlessSync/Plugin.cs +++ b/LightlessSync/Plugin.cs @@ -246,6 +246,7 @@ public sealed class Plugin : IDalamudPlugin collection.AddScoped(); collection.AddScoped(); collection.AddScoped(); + collection.AddScoped(); collection.AddScoped((s) => new EditProfileUi(s.GetRequiredService>(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), diff --git a/LightlessSync/Services/BroadcastService.cs b/LightlessSync/Services/BroadcastService.cs index 0cdd3c0..81e54d5 100644 --- a/LightlessSync/Services/BroadcastService.cs +++ b/LightlessSync/Services/BroadcastService.cs @@ -1,4 +1,8 @@ -using LightlessSync.API.Dto.Group; +using Dalamud.Interface; +using LightlessSync.LightlessConfiguration.Models; +using LightlessSync.UI; +using LightlessSync.UI.Models; +using LightlessSync.API.Dto.Group; using LightlessSync.API.Dto.User; using LightlessSync.LightlessConfiguration; using LightlessSync.Services.Mediator; @@ -140,6 +144,11 @@ public class BroadcastService : IHostedService, IMediatorSubscriber IsLightFinderAvailable = false; ApplyBroadcastDisabled(forcePublish: true); _logger.LogDebug("Cleared Lightfinder state due to disconnect."); + + _mediator.Publish(new NotificationMessage( + "Disconnected from Server", + "Your Lightfinder broadcast has been disabled due to disconnection.", + NotificationType.Warning)); } public Task StartAsync(CancellationToken cancellationToken) @@ -236,6 +245,11 @@ public class BroadcastService : IHostedService, IMediatorSubscriber { _logger.LogInformation("Auto-enabling Lightfinder broadcast after reconnect."); _mediator.Publish(new EnableBroadcastMessage(hashedCid, true)); + + _mediator.Publish(new NotificationMessage( + "Broadcast Auto-Enabled", + "Your Lightfinder broadcast has been automatically enabled.", + NotificationType.Info)); } } catch (OperationCanceledException) @@ -391,9 +405,14 @@ public class BroadcastService : IHostedService, IMediatorSubscriber public async void ToggleBroadcast() { + if (!IsLightFinderAvailable) { _logger.LogWarning("ToggleBroadcast - Lightfinder is not available."); + _mediator.Publish(new NotificationMessage( + "Broadcast Unavailable", + "Lightfinder is not available on this server.", + NotificationType.Error)); return; } @@ -403,6 +422,10 @@ public class BroadcastService : IHostedService, IMediatorSubscriber if (!_config.Current.BroadcastEnabled && cooldown is { } cd && cd > TimeSpan.Zero) { _logger.LogWarning("Cooldown active. Must wait {Remaining}s before re-enabling.", cd.TotalSeconds); + _mediator.Publish(new NotificationMessage( + "Broadcast Cooldown", + $"Please wait {cd.TotalSeconds:F0} seconds before re-enabling broadcast.", + NotificationType.Warning)); return; } @@ -427,10 +450,19 @@ public class BroadcastService : IHostedService, IMediatorSubscriber _logger.LogDebug("Toggling broadcast. Server currently broadcasting: {ServerStatus}, setting to: {NewStatus}", isCurrentlyBroadcasting, newStatus); _mediator.Publish(new EnableBroadcastMessage(hashedCid, newStatus)); + + _mediator.Publish(new NotificationMessage( + newStatus ? "Broadcast Enabled" : "Broadcast Disabled", + newStatus ? "Your Lightfinder broadcast has been enabled." : "Your Lightfinder broadcast has been disabled.", + NotificationType.Info)); } catch (Exception ex) { _logger.LogError(ex, "Failed to determine current broadcast status for toggle"); + _mediator.Publish(new NotificationMessage( + "Broadcast Toggle Failed", + $"Failed to toggle broadcast: {ex.Message}", + NotificationType.Error)); } }).ConfigureAwait(false); } @@ -493,6 +525,7 @@ public class BroadcastService : IHostedService, IMediatorSubscriber { _logger.LogDebug("Broadcast TTL expired. Disabling broadcast locally."); ApplyBroadcastDisabled(forcePublish: true); + ShowBroadcastExpiredNotification(); } } else @@ -501,4 +534,49 @@ public class BroadcastService : IHostedService, IMediatorSubscriber } }).ConfigureAwait(false); } + + private void ShowBroadcastExpiredNotification() + { + var notification = new LightlessNotification + { + Id = "broadcast_expired", + Title = "Broadcast Expired", + Message = "Your Lightfinder broadcast has expired after 3 hours. Would you like to re-enable it?", + Type = NotificationType.PairRequest, + Duration = TimeSpan.FromSeconds(180), + Actions = new List + { + new() + { + Id = "re_enable", + Label = "Re-enable", + Icon = FontAwesomeIcon.Plus, + Color = UIColors.Get("PairBlue"), + IsPrimary = true, + OnClick = (n) => + { + _logger.LogInformation("Re-enabling broadcast from notification"); + ToggleBroadcast(); + n.IsDismissed = true; + n.IsAnimatingOut = true; + } + }, + new() + { + Id = "close", + Label = "Close", + Icon = FontAwesomeIcon.Times, + Color = UIColors.Get("DimRed"), + OnClick = (n) => + { + _logger.LogInformation("Broadcast expiration notification dismissed"); + n.IsDismissed = true; + n.IsAnimatingOut = true; + } + } + } + }; + + _mediator.Publish(new LightlessNotificationMessage(notification)); + } } \ No newline at end of file diff --git a/LightlessSync/UI/Models/Changelog.cs b/LightlessSync/UI/Models/Changelog.cs new file mode 100644 index 0000000..23d26c4 --- /dev/null +++ b/LightlessSync/UI/Models/Changelog.cs @@ -0,0 +1,43 @@ +namespace LightlessSync.UI.Models +{ + public class ChangelogFile + { + public string Tagline { get; init; } = string.Empty; + public string Subline { get; init; } = string.Empty; + public List Changelog { get; init; } = new(); + public List? Credits { get; init; } + } + + public class ChangelogEntry + { + public string Name { get; init; } = string.Empty; + public string Date { get; init; } = string.Empty; + public string Tagline { get; init; } = string.Empty; + public bool? IsCurrent { get; init; } + public string? Message { get; init; } + public List? Versions { get; init; } + } + + public class ChangelogVersion + { + public string Number { get; init; } = string.Empty; + public List Items { get; init; } = new(); + } + + public class CreditCategory + { + public string Category { get; init; } = string.Empty; + public List Items { get; init; } = new(); + } + + public class CreditItem + { + public string Name { get; init; } = string.Empty; + public string Role { get; init; } = string.Empty; + } + + public class CreditsFile + { + public List Credits { get; init; } = new(); + } +} \ No newline at end of file diff --git a/LightlessSync/UI/UpdateNotesUi.cs b/LightlessSync/UI/UpdateNotesUi.cs new file mode 100644 index 0000000..57dd173 --- /dev/null +++ b/LightlessSync/UI/UpdateNotesUi.cs @@ -0,0 +1,751 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Interface.Utility; +using Dalamud.Interface.Utility.Raii; +using Dalamud.Utility; +using LightlessSync.LightlessConfiguration; +using LightlessSync.Services; +using LightlessSync.Services.Mediator; +using Microsoft.Extensions.Logging; +using System.Numerics; +using System.Reflection; +using System.Text; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; +using Dalamud.Interface; +using LightlessSync.UI.Models; + +namespace LightlessSync.UI; + +// Inspiration taken from Brio and Character Select+ (goats) +public class UpdateNotesUi : WindowMediatorSubscriberBase +{ + private readonly UiSharedService _uiShared; + private readonly LightlessConfigService _configService; + + private ChangelogFile _changelog = new(); + private CreditsFile _credits = new(); + private bool _scrollToTop; + private int _selectedTab; + private bool _hasInitializedCollapsingHeaders; + + private struct Particle + { + public Vector2 Position; + public Vector2 Velocity; + public float Life; + public float MaxLife; + public float Size; + public ParticleType Type; + public List? Trail; + public float Twinkle; + public float Depth; + public float Hue; + } + + private enum ParticleType + { + TwinklingStar, + ShootingStar + } + + private readonly List _particles = []; + private float _particleSpawnTimer; + private readonly Random _random = new(); + + private const float HeaderHeight = 150f; + private const float ParticleSpawnInterval = 0.2f; + private const int MaxParticles = 50; + private const int MaxTrailLength = 50; + private const float EdgeFadeDistance = 30f; + private const float ExtendedParticleHeight = 40f; + + public UpdateNotesUi(ILogger logger, + LightlessMediator mediator, + UiSharedService uiShared, + LightlessConfigService configService, + PerformanceCollectorService performanceCollectorService) + : base(logger, mediator, "Lightless Sync — Update Notes", performanceCollectorService) + { + logger.LogInformation("UpdateNotesUi constructor called"); + _uiShared = uiShared; + _configService = configService; + + AllowClickthrough = false; + AllowPinning = false; + RespectCloseHotkey = true; + ShowCloseButton = true; + + Flags = ImGuiWindowFlags.NoSavedSettings | ImGuiWindowFlags.NoResize | ImGuiWindowFlags.NoCollapse | + ImGuiWindowFlags.NoTitleBar; + + SizeConstraints = new WindowSizeConstraints() + { + MinimumSize = new Vector2(800, 700), MaximumSize = new Vector2(800, 700), + }; + + LoadEmbeddedResources(); + logger.LogInformation("UpdateNotesUi constructor completed successfully"); + } + + public override void OnOpen() + { + _scrollToTop = true; + _hasInitializedCollapsingHeaders = false; + } + + protected override void DrawInternal() + { + if (_uiShared.IsInGpose) + return; + + DrawHeader(); + ImGuiHelpers.ScaledDummy(6); + DrawTabs(); + DrawCloseButton(); + } + + private void DrawHeader() + { + var windowPos = ImGui.GetWindowPos(); + var windowPadding = ImGui.GetStyle().WindowPadding; + var headerWidth = (800f * ImGuiHelpers.GlobalScale) - (windowPadding.X * 2); + + var headerStart = windowPos + new Vector2(windowPadding.X, windowPadding.Y); + var headerEnd = headerStart + new Vector2(headerWidth, HeaderHeight); + + var extendedParticleSize = new Vector2(headerWidth, HeaderHeight + ExtendedParticleHeight); + + DrawGradientBackground(headerStart, headerEnd); + DrawHeaderText(headerStart); + DrawHeaderButtons(headerStart, headerWidth); + DrawBottomGradient(headerStart, headerEnd, headerWidth); + + ImGui.SetCursorPosY(windowPadding.Y + HeaderHeight + 5); + ImGui.SetCursorPosX(20); + using (ImRaii.PushFont(UiBuilder.IconFont)) + { + ImGui.TextColored(UIColors.Get("LightlessGreen"), FontAwesomeIcon.Star.ToIconString()); + } + + ImGui.SameLine(); + + ImGui.TextColored(UIColors.Get("LightlessGreen"), "What's New"); + + if (!string.IsNullOrEmpty(_changelog.Tagline)) + { + ImGui.SameLine(); + ImGui.SetCursorPosX(ImGui.GetCursorPosX() + 10); + ImGui.TextColored(new Vector4(0.75f, 0.75f, 0.85f, 1.0f), _changelog.Tagline); + + if (!string.IsNullOrEmpty(_changelog.Subline)) + { + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.65f, 0.65f, 0.75f, 1.0f), $" – {_changelog.Subline}"); + } + } + + ImGuiHelpers.ScaledDummy(3); + + DrawParticleEffects(headerStart, extendedParticleSize); + } + + private void DrawGradientBackground(Vector2 headerStart, Vector2 headerEnd) + { + var drawList = ImGui.GetWindowDrawList(); + + var darkPurple = new Vector4(0.08f, 0.05f, 0.15f, 1.0f); + var deepPurple = new Vector4(0.12f, 0.08f, 0.20f, 1.0f); + + drawList.AddRectFilledMultiColor( + headerStart, + headerEnd, + ImGui.GetColorU32(darkPurple), + ImGui.GetColorU32(darkPurple), + ImGui.GetColorU32(deepPurple), + ImGui.GetColorU32(deepPurple) + ); + + var random = new Random(42); + for (int i = 0; i < 50; i++) + { + var starPos = headerStart + new Vector2( + (float)random.NextDouble() * (headerEnd.X - headerStart.X), + (float)random.NextDouble() * (headerEnd.Y - headerStart.Y) + ); + var brightness = 0.3f + (float)random.NextDouble() * 0.4f; + drawList.AddCircleFilled(starPos, 1f, ImGui.GetColorU32(new Vector4(1f, 1f, 1f, brightness))); + } + } + + private void DrawBottomGradient(Vector2 headerStart, Vector2 headerEnd, float width) + { + var drawList = ImGui.GetWindowDrawList(); + var gradientHeight = 60f; + + for (int i = 0; i < gradientHeight; i++) + { + var progress = i / gradientHeight; + var smoothProgress = progress * progress; + var r = 0.12f + (0.0f - 0.12f) * smoothProgress; + var g = 0.08f + (0.0f - 0.08f) * smoothProgress; + var b = 0.20f + (0.0f - 0.20f) * smoothProgress; + var alpha = 1f - smoothProgress; + var gradientColor = new Vector4(r, g, b, alpha); + drawList.AddLine( + new Vector2(headerStart.X, headerEnd.Y + i), + new Vector2(headerStart.X + width, headerEnd.Y + i), + ImGui.GetColorU32(gradientColor), + 1f + ); + } + } + + private void DrawHeaderText(Vector2 headerStart) + { + var textX = 20f; + var textY = 30f; + + ImGui.SetCursorScreenPos(headerStart + new Vector2(textX, textY)); + + using (_uiShared.UidFont.Push()) + { + ImGui.TextColored(new Vector4(0.95f, 0.95f, 0.95f, 1.0f), "Lightless Sync"); + } + + ImGui.SetCursorScreenPos(headerStart + new Vector2(textX, textY + 45f)); + ImGui.TextColored(UIColors.Get("LightlessBlue"), "Update Notes"); + } + + private void DrawHeaderButtons(Vector2 headerStart, float headerWidth) + { + var buttonSize = _uiShared.GetIconButtonSize(FontAwesomeIcon.Globe); + var spacing = 8f * ImGuiHelpers.GlobalScale; + var rightPadding = 15f * ImGuiHelpers.GlobalScale; + var topPadding = 15f * ImGuiHelpers.GlobalScale; + var buttonY = headerStart.Y + topPadding; + var gitButtonX = headerStart.X + headerWidth - rightPadding - buttonSize.X; + var discordButtonX = gitButtonX - buttonSize.X - spacing; + + ImGui.SetCursorScreenPos(new Vector2(discordButtonX, buttonY)); + + using (ImRaii.PushColor(ImGuiCol.Button, new Vector4(0, 0, 0, 0))) + using (ImRaii.PushColor(ImGuiCol.ButtonHovered, UIColors.Get("LightlessPurple") with { W = 0.3f })) + using (ImRaii.PushColor(ImGuiCol.ButtonActive, UIColors.Get("LightlessPurpleActive") with { W = 0.5f })) + { + if (_uiShared.IconButton(FontAwesomeIcon.Comments)) + { + Util.OpenLink("https://discord.gg/dsbjcXMnhA"); + } + + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("Join our Discord"); + } + + ImGui.SetCursorScreenPos(new Vector2(gitButtonX, buttonY)); + if (_uiShared.IconButton(FontAwesomeIcon.Code)) + { + Util.OpenLink("https://git.lightless-sync.org/Lightless-Sync"); + } + + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("View on Git"); + } + } + } + + private void DrawParticleEffects(Vector2 bannerStart, Vector2 bannerSize) + { + var deltaTime = ImGui.GetIO().DeltaTime; + _particleSpawnTimer += deltaTime; + + if (_particleSpawnTimer > ParticleSpawnInterval && _particles.Count < MaxParticles) + { + SpawnParticle(bannerSize); + _particleSpawnTimer = 0f; + } + + if (_random.NextDouble() < 0.003) + { + SpawnShootingStar(bannerSize); + } + + var drawList = ImGui.GetWindowDrawList(); + + for (int i = _particles.Count - 1; i >= 0; i--) + { + var particle = _particles[i]; + + var screenPos = bannerStart + particle.Position; + + if (particle.Type == ParticleType.ShootingStar && particle.Trail != null) + { + particle.Trail.Insert(0, particle.Position); + if (particle.Trail.Count > MaxTrailLength) + particle.Trail.RemoveAt(particle.Trail.Count - 1); + } + + if (particle.Type == ParticleType.TwinklingStar) + { + particle.Twinkle += 0.005f * particle.Depth; + } + + particle.Position += particle.Velocity * deltaTime; + particle.Life -= deltaTime; + + var isOutOfBounds = particle.Position.X < -50 || particle.Position.X > bannerSize.X + 50 || + particle.Position.Y < -50 || particle.Position.Y > bannerSize.Y + 50; + + if (particle.Life <= 0 || (particle.Type != ParticleType.TwinklingStar && isOutOfBounds)) + { + _particles.RemoveAt(i); + continue; + } + + if (particle.Type == ParticleType.TwinklingStar) + { + if (particle.Position.X < 0 || particle.Position.X > bannerSize.X) + particle.Velocity = particle.Velocity with { X = -particle.Velocity.X }; + if (particle.Position.Y < 0 || particle.Position.Y > bannerSize.Y) + particle.Velocity = particle.Velocity with { Y = -particle.Velocity.Y }; + } + + var fadeIn = Math.Min(1f, (particle.MaxLife - particle.Life) / 20f); + var fadeOut = Math.Min(1f, particle.Life / 20f); + var lifeFade = Math.Min(fadeIn, fadeOut); + + var edgeFadeX = Math.Min( + Math.Min(1f, (particle.Position.X + EdgeFadeDistance) / EdgeFadeDistance), + Math.Min(1f, (bannerSize.X - particle.Position.X + EdgeFadeDistance) / EdgeFadeDistance) + ); + var edgeFadeY = Math.Min( + Math.Min(1f, (particle.Position.Y + EdgeFadeDistance) / EdgeFadeDistance), + Math.Min(1f, (bannerSize.Y - particle.Position.Y + EdgeFadeDistance) / EdgeFadeDistance) + ); + var edgeFade = Math.Min(edgeFadeX, edgeFadeY); + + var baseAlpha = lifeFade * edgeFade; + var finalAlpha = particle.Type == ParticleType.TwinklingStar + ? baseAlpha * (0.6f + 0.4f * MathF.Sin(particle.Twinkle)) + : baseAlpha; + + if (particle.Type == ParticleType.ShootingStar && particle.Trail != null && particle.Trail.Count > 1) + { + var cyanColor = new Vector4(0.4f, 0.8f, 1.0f, 1.0f); + + for (int t = 1; t < particle.Trail.Count; t++) + { + var trailProgress = (float)t / particle.Trail.Count; + var trailAlpha = Math.Min(1f, (1f - trailProgress) * finalAlpha * 1.8f); + var trailWidth = (1f - trailProgress) * 3f + 1f; + + var glowAlpha = trailAlpha * 0.4f; + drawList.AddLine( + bannerStart + particle.Trail[t - 1], + bannerStart + particle.Trail[t], + ImGui.GetColorU32(cyanColor with { W = glowAlpha }), + trailWidth + 4f + ); + + drawList.AddLine( + bannerStart + particle.Trail[t - 1], + bannerStart + particle.Trail[t], + ImGui.GetColorU32(cyanColor with { W = trailAlpha }), + trailWidth + ); + } + } + else if (particle.Type == ParticleType.TwinklingStar) + { + DrawTwinklingStar(drawList, screenPos, particle.Size, particle.Hue, finalAlpha, particle.Depth); + } + + _particles[i] = particle; + } + } + + private void DrawTwinklingStar(ImDrawListPtr drawList, Vector2 position, float size, float hue, float alpha, + float depth) + { + var color = HslToRgb(hue, 1.0f, 0.85f); + color.W = alpha; + + drawList.AddCircleFilled(position, size, ImGui.GetColorU32(color)); + + var glowColor = color with { W = alpha * 0.3f }; + drawList.AddCircleFilled(position, size * (1.2f + depth * 0.3f), ImGui.GetColorU32(glowColor)); + } + + private static Vector4 HslToRgb(float h, float s, float l) + { + h = h / 360f; + float c = (1 - MathF.Abs(2 * l - 1)) * s; + float x = c * (1 - MathF.Abs((h * 6) % 2 - 1)); + float m = l - c / 2; + + float r, g, b; + if (h < 1f / 6f) + { + r = c; + g = x; + b = 0; + } + else if (h < 2f / 6f) + { + r = x; + g = c; + b = 0; + } + else if (h < 3f / 6f) + { + r = 0; + g = c; + b = x; + } + else if (h < 4f / 6f) + { + r = 0; + g = x; + b = c; + } + else if (h < 5f / 6f) + { + r = x; + g = 0; + b = c; + } + else + { + r = c; + g = 0; + b = x; + } + + return new Vector4(r + m, g + m, b + m, 1.0f); + } + + + private void SpawnParticle(Vector2 bannerSize) + { + var position = new Vector2( + (float)_random.NextDouble() * bannerSize.X, + (float)_random.NextDouble() * bannerSize.Y + ); + + var depthLayers = new[] { 0.5f, 1.0f, 1.5f }; + var depth = depthLayers[_random.Next(depthLayers.Length)]; + + var velocity = new Vector2( + ((float)_random.NextDouble() - 0.5f) * 0.05f * depth, + ((float)_random.NextDouble() - 0.5f) * 0.05f * depth + ); + + var isBlue = _random.NextDouble() < 0.5; + var hue = isBlue ? 220f + (float)_random.NextDouble() * 30f : 270f + (float)_random.NextDouble() * 40f; + var size = (0.5f + (float)_random.NextDouble() * 2f) * depth; + var maxLife = 120f + (float)_random.NextDouble() * 60f; + + _particles.Add(new Particle + { + Position = position, + Velocity = velocity, + Life = maxLife, + MaxLife = maxLife, + Size = size, + Type = ParticleType.TwinklingStar, + Trail = null, + Twinkle = (float)_random.NextDouble() * MathF.PI * 2, + Depth = depth, + Hue = hue + }); + } + + private void SpawnShootingStar(Vector2 bannerSize) + { + var maxLife = 80f + (float)_random.NextDouble() * 40f; + var startX = bannerSize.X * (0.3f + (float)_random.NextDouble() * 0.6f); + var startY = -10f; + + _particles.Add(new Particle + { + Position = new Vector2(startX, startY), + Velocity = new Vector2( + -50f - (float)_random.NextDouble() * 40f, + 30f + (float)_random.NextDouble() * 40f + ), + Life = maxLife, + MaxLife = maxLife, + Size = 2.5f, + Type = ParticleType.ShootingStar, + Trail = new List(), + Twinkle = 0, + Depth = 1.0f, + Hue = 270f + }); + } + + + private void DrawTabs() + { + using (ImRaii.PushStyle(ImGuiStyleVar.FrameRounding, 6f)) + using (ImRaii.PushColor(ImGuiCol.Tab, UIColors.Get("ButtonDefault"))) + using (ImRaii.PushColor(ImGuiCol.TabHovered, UIColors.Get("LightlessPurple"))) + using (ImRaii.PushColor(ImGuiCol.TabActive, UIColors.Get("LightlessPurpleActive"))) + { + using (var tabBar = ImRaii.TabBar("###ll_tabs", ImGuiTabBarFlags.None)) + { + if (!tabBar) + return; + + using (var changelogTab = ImRaii.TabItem("Changelog")) + { + if (changelogTab) + { + _selectedTab = 0; + DrawChangelog(); + } + } + + if (_credits.Credits != null && _credits.Credits.Count > 0) + { + using (var creditsTab = ImRaii.TabItem("Credits")) + { + if (creditsTab) + { + _selectedTab = 1; + DrawCredits(); + } + } + } + } + } + } + + private void DrawCredits() + { + using (ImRaii.PushStyle(ImGuiStyleVar.ChildRounding, 6f)) + using (var child = ImRaii.Child("###ll_credits", new Vector2(0, ImGui.GetContentRegionAvail().Y - 60), false, + ImGuiWindowFlags.AlwaysVerticalScrollbar)) + { + if (!child) + return; + + ImGui.PushTextWrapPos(); + + if (_credits.Credits != null) + { + foreach (var category in _credits.Credits) + { + DrawCreditCategory(category); + ImGuiHelpers.ScaledDummy(10); + } + } + + ImGui.PopTextWrapPos(); + ImGui.Spacing(); + } + } + + private void DrawCreditCategory(CreditCategory category) + { + DrawFeatureSection(category.Category, UIColors.Get("LightlessBlue")); + + foreach (var item in category.Items) + { + if (!string.IsNullOrEmpty(item.Role)) + { + ImGui.BulletText($"{item.Name} — {item.Role}"); + } + else + { + ImGui.BulletText(item.Name); + } + } + + ImGuiHelpers.ScaledDummy(5); + } + + private void DrawCloseButton() + { + ImGuiHelpers.ScaledDummy(5); + + var closeWidth = 200f * ImGuiHelpers.GlobalScale; + var closeHeight = 35f * ImGuiHelpers.GlobalScale; + ImGui.SetCursorPosX((ImGui.GetWindowSize().X - closeWidth) / 2); + + using (ImRaii.PushStyle(ImGuiStyleVar.FrameRounding, 8f)) + using (ImRaii.PushColor(ImGuiCol.Button, UIColors.Get("LightlessPurple"))) + using (ImRaii.PushColor(ImGuiCol.ButtonHovered, UIColors.Get("LightlessPurpleActive"))) + using (ImRaii.PushColor(ImGuiCol.ButtonActive, UIColors.Get("ButtonDefault"))) + { + if (ImGui.Button("Got it!", new Vector2(closeWidth, closeHeight))) + { + // Update last seen version when user acknowledges the update notes + var ver = Assembly.GetExecutingAssembly().GetName().Version; + var currentVersion = ver == null ? string.Empty : $"{ver.Major}.{ver.Minor}.{ver.Build}"; + _configService.Current.LastSeenVersion = currentVersion; + _configService.Save(); + + IsOpen = false; + } + } + } + + private void DrawChangelog() + { + using (ImRaii.PushStyle(ImGuiStyleVar.ChildRounding, 6f)) + using (var child = ImRaii.Child("###ll_changelog", new Vector2(0, ImGui.GetContentRegionAvail().Y - 60), false, + ImGuiWindowFlags.AlwaysVerticalScrollbar)) + { + if (!child) + return; + + if (_scrollToTop) + { + _scrollToTop = false; + ImGui.SetScrollHereY(0); + } + + ImGui.PushTextWrapPos(); + + foreach (var entry in _changelog.Changelog) + { + DrawChangelogEntry(entry); + } + + _hasInitializedCollapsingHeaders = true; + + ImGui.PopTextWrapPos(); + ImGui.Spacing(); + } + } + + private void DrawChangelogEntry(ChangelogEntry entry) + { + var isCurrent = entry.IsCurrent ?? false; + + var currentColor = isCurrent + ? UIColors.Get("LightlessGreen") + : new Vector4(0.95f, 0.95f, 1.0f, 1.0f); + + var flags = isCurrent ? ImGuiTreeNodeFlags.DefaultOpen : ImGuiTreeNodeFlags.None; + + if (!_hasInitializedCollapsingHeaders) + { + ImGui.SetNextItemOpen(isCurrent, ImGuiCond.Always); + } + + bool isOpen; + using (ImRaii.PushStyle(ImGuiStyleVar.FrameRounding, 4f)) + using (ImRaii.PushColor(ImGuiCol.Header, UIColors.Get("ButtonDefault"))) + using (ImRaii.PushColor(ImGuiCol.Text, currentColor)) + { + isOpen = ImGui.CollapsingHeader($" {entry.Name} — {entry.Date} ", flags); + + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.85f, 0.85f, 0.95f, 1.0f), $" — {entry.Tagline}"); + } + + if (!isOpen) + return; + + ImGuiHelpers.ScaledDummy(8); + + if (!string.IsNullOrEmpty(entry.Message)) + { + ImGui.TextWrapped(entry.Message); + ImGuiHelpers.ScaledDummy(8); + return; + } + + if (entry.Versions != null) + { + foreach (var version in entry.Versions) + { + DrawFeatureSection(version.Number, UIColors.Get("LightlessGreen")); + + foreach (var item in version.Items) + { + ImGui.BulletText(item); + } + + ImGuiHelpers.ScaledDummy(5); + } + } + } + + private static void DrawFeatureSection(string title, Vector4 accentColor) + { + var drawList = ImGui.GetWindowDrawList(); + var startPos = ImGui.GetCursorScreenPos(); + var availableWidth = ImGui.GetContentRegionAvail().X; + + var backgroundMin = startPos + new Vector2(-8, -4); + var backgroundMax = startPos + new Vector2(availableWidth + 8, 28); + + var bgColor = new Vector4(0.12f, 0.12f, 0.15f, 0.7f); + drawList.AddRectFilled(backgroundMin, backgroundMax, ImGui.GetColorU32(bgColor), 6f); + + drawList.AddRectFilled( + backgroundMin, + backgroundMin + new Vector2(4, backgroundMax.Y - backgroundMin.Y), + ImGui.GetColorU32(accentColor), + 3f + ); + + var glowColor = accentColor with { W = 0.15f }; + drawList.AddRect( + backgroundMin, + backgroundMax, + ImGui.GetColorU32(glowColor), + 6f, + ImDrawFlags.None, + 1.5f + ); + + // Calculate vertical centering + var textSize = ImGui.CalcTextSize(title); + var boxHeight = backgroundMax.Y - backgroundMin.Y; + var verticalOffset = (boxHeight - textSize.Y) / 5f; + + ImGui.SetCursorPosX(ImGui.GetCursorPosX() + 8); + ImGui.SetCursorPosY(ImGui.GetCursorPosY() + verticalOffset); + ImGui.TextColored(accentColor, title); + ImGui.SetCursorPosY(backgroundMax.Y - startPos.Y + ImGui.GetCursorPosY()); + } + + private void LoadEmbeddedResources() + { + try + { + var assembly = Assembly.GetExecutingAssembly(); + var deserializer = new DeserializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .IgnoreUnmatchedProperties() + .Build(); + + // Load changelog + using var changelogStream = assembly.GetManifestResourceStream("LightlessSync.Changelog.changelog.yaml"); + if (changelogStream != null) + { + using var reader = new StreamReader(changelogStream, Encoding.UTF8, true, 128); + var yaml = reader.ReadToEnd(); + _changelog = deserializer.Deserialize(yaml) ?? new(); + } + + // Load credits + using var creditsStream = assembly.GetManifestResourceStream("LightlessSync.Changelog.credits.yaml"); + if (creditsStream != null) + { + using var reader = new StreamReader(creditsStream, Encoding.UTF8, true, 128); + var yaml = reader.ReadToEnd(); + _credits = deserializer.Deserialize(yaml) ?? new(); + } + } + catch + { + // Ignore - window will gracefully render with defaults + } + } +}