diff --git a/.gitignore b/.gitignore index 6b21231..dd9d46b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .vs/ +.vscode/ /DynamicWin/bin/ /DynamicWin/obj/ /DynamicWinSetup diff --git a/DynamicWin/DynamicWin.csproj b/DynamicWin/DynamicWin.csproj index 21210b7..546109e 100644 --- a/DynamicWin/DynamicWin.csproj +++ b/DynamicWin/DynamicWin.csproj @@ -41,14 +41,14 @@ - + - + diff --git a/DynamicWin/Main/App.xaml.cs b/DynamicWin/Main/App.xaml.cs index 8c6b4fd..0ba6dda 100644 --- a/DynamicWin/Main/App.xaml.cs +++ b/DynamicWin/Main/App.xaml.cs @@ -20,7 +20,7 @@ public partial class DynamicWinMain : System.Windows.Application public static MMDevice? defaultDevice; public static MMDevice? defaultMicrophone; - public static string Version => "v1.6.1r"; + public static string Version => "v1.7.0r"; public static Channel ReleaseStream => Channel.Release; public static Architecture ProcessArchitecture => RuntimeInformation.ProcessArchitecture; @@ -96,6 +96,8 @@ protected override void OnStartup(StartupEventArgs e) try { MediaInfo.Initialize(); + // Also initialise the thumbnail service to start event loop + _ = MediaThumbnailService.Instance; } catch { } @@ -134,6 +136,7 @@ protected override void OnStartup(StartupEventArgs e) protected override void OnExit(ExitEventArgs e) { + AppBarHelper.ForceUnregisterLast(); base.OnExit(e); try @@ -200,7 +203,7 @@ private void UpdateWindowPosition() try { if (mainForm == null) return; - WindowPositionHelper.CenterWindowOnMonitor(mainForm, Settings.ScreenIndex); + mainForm.UpdateWindowConfiguration(); } catch { } } diff --git a/DynamicWin/Main/MainForm.xaml.cs b/DynamicWin/Main/MainForm.xaml.cs index 6b4ae5f..3f066ed 100644 --- a/DynamicWin/Main/MainForm.xaml.cs +++ b/DynamicWin/Main/MainForm.xaml.cs @@ -2,6 +2,7 @@ using DynamicWin.UI.Menu; using DynamicWin.UI.Menu.Menus; using DynamicWin.Utils; +using System.ComponentModel; using System.Diagnostics; using System.Reflection.Metadata; using System.Runtime.InteropServices; @@ -25,6 +26,7 @@ public partial class MainForm : Window internal Forms.ToolStripMenuItem _settingsTrayItem; + public static IntPtr Handle { get; private set; } private DateTime _lastRenderTime; // Target interval driven by monitor refresh rate (set in ctor) @@ -35,11 +37,24 @@ public partial class MainForm : Window // Mouse/motion tracking for idle detection private System.Windows.Point _lastMousePos = new System.Windows.Point(-1, -1); private DateTime _lastMouseMoveTime = DateTime.MinValue; - private readonly TimeSpan _idleMouseThreshold = TimeSpan.FromSeconds(1.0); + private readonly TimeSpan _idleMouseThreshold = TimeSpan.FromMilliseconds(180); + private readonly TimeSpan _refreshRatePollInterval = TimeSpan.FromSeconds(2.0); + private int _cachedRefreshRate = 60; + private DateTime _lastRefreshRateCheck = DateTime.MinValue; + private DateTime _renderBurstUntil = DateTime.MinValue; + private const int ContinuousRenderHz = 60; + private const int StaticIdleRenderHz = 8; // Rendering pause flag (used for suspend/hibernate) private bool _renderPaused = false; + public void RequestRenderBurst(int milliseconds = 350) + { + var until = DateTime.UtcNow.AddMilliseconds(Math.Max(1, milliseconds)); + if (until > _renderBurstUntil) + _renderBurstUntil = until; + } + #region Win32 API Definitions [DllImport("user32.dll")] @@ -76,18 +91,10 @@ public MainForm() // Initialise mouse tracking _lastMouseMoveTime = DateTime.UtcNow; - // Compute initial target frame interval from monitor refresh rate - try - { - int refresh = DisplayHelper.GetRefreshRate(); - if (refresh <= 0) refresh = 60; - _targetElapsedTime = TimeSpan.FromMilliseconds(1000.0 / refresh); - Debug.WriteLine($"[MAIN FORM] Initial target frame interval: {_targetElapsedTime.TotalMilliseconds} ms ({refresh} Hz)"); - } - catch - { - _targetElapsedTime = TimeSpan.FromMilliseconds(16); - } + // Compute initial target frame interval from the configured monitor. + RefreshCachedRefreshRate(DateTime.UtcNow, true); + _targetElapsedTime = TimeSpan.FromMilliseconds(1000.0 / _cachedRefreshRate); + Debug.WriteLine($"[MAIN FORM] Initial target frame interval: {_targetElapsedTime.TotalMilliseconds} ms ({_cachedRefreshRate} Hz)"); CompositionTarget.Rendering += OnRendering; @@ -96,6 +103,11 @@ public MainForm() this.WindowStyle = WindowStyle.None; this.WindowState = WindowState.Maximized; this.ResizeMode = ResizeMode.NoResize; + this.SourceInitialized += (s, e) => + { + Handle = new WindowInteropHelper(this).Handle; + UpdateWindowConfiguration(); + }; this.Topmost = true; this.AllowsTransparency = true; this.ShowInTaskbar = false; @@ -138,7 +150,7 @@ public MainForm() this.Topmost = true; }; - _trayIcon.ContextMenuStrip.Items.Add("Restart Control", ContextMenuUtils.LoadTrayBitmap("Resources/icons/context/refresh.png"), (x, y) => + _trayIcon.ContextMenuStrip.Items.Add("Restart control", ContextMenuUtils.LoadTrayBitmap("Resources/icons/context/refresh.png"), (x, y) => { if (RendererMain.Instance != null) RendererMain.Instance.Destroy(); this.Content = new Grid(); @@ -157,8 +169,9 @@ public MainForm() _trayIcon.ContextMenuStrip.Items.Add("Exit", ContextMenuUtils.LoadTrayBitmap("Resources/icons/context/exit.png"), (x, y) => { - SaveManager.SaveAll(); - Process.GetCurrentProcess().Kill(); + _trayIcon.Visible = false; + AppBarHelper.UnregisterAppBar(this); + Application.Current.Shutdown(); }); _trayIcon.Visible = true; @@ -188,14 +201,27 @@ public void SetMonitor(int monitorIndex) this.WindowState = WindowState.Normal; this.ResizeMode = ResizeMode.CanResize; - WindowPositionHelper.CenterWindowOnMonitor(this, clampedIndex); + UpdateWindowConfiguration(); this.ResizeMode = ResizeMode.NoResize; + RefreshCachedRefreshRate(DateTime.UtcNow, true); + } - // Move the window in App.xaml.cs as well - if (System.Windows.Application.Current is DynamicWinMain app) - { - app.MoveToMonitor(clampedIndex); - } + public void UpdateWindowConfiguration() + { + WindowPositionHelper.CenterWindowOnMonitor(this, Settings.ScreenIndex); + ApplyWorkingArea(); + } + + private void ApplyWorkingArea() + { + if (Settings.ReduceWorkingArea) AppBarHelper.RegisterAppBar(this, 40); + else AppBarHelper.UnregisterAppBar(this); + } + + protected override void OnClosing(CancelEventArgs e) + { + AppBarHelper.UnregisterAppBar(this); + base.OnClosing(e); } public static int GetMonitorCount() @@ -205,6 +231,8 @@ public static int GetMonitorCount() private void OnRendering(object? sender, EventArgs e) { + if (_renderPaused) return; + var now = DateTime.UtcNow; // Track mouse movement to detect idle while hovering the island @@ -222,47 +250,92 @@ private void OnRendering(object? sender, EventArgs e) // Decide refresh rate dynamically based on settings and idle state try { - TimeSpan desiredInterval = TimeSpan.FromMilliseconds(16); + RefreshCachedRefreshRate(now); + int displayRefresh = _cachedRefreshRate; + if (displayRefresh <= 0) displayRefresh = 60; + + int fullRefreshHz = Settings.ToggleHighRefreshRate + ? displayRefresh + : Math.Min(60, displayRefresh); + int targetHz = fullRefreshHz; if (Settings.ToggleHighRefreshRate) { - int displayRefresh = DisplayHelper.GetRefreshRate(); - if (displayRefresh <= 0) displayRefresh = 60; - - int targetHz = displayRefresh; + fullRefreshHz = displayRefresh; + } - if (Settings.LimitRefreshRateWhenIdle) + if (Settings.LimitRefreshRateWhenIdle) + { + bool leftMouseDown = false; + try { leftMouseDown = System.Windows.Input.Mouse.LeftButton == System.Windows.Input.MouseButtonState.Pressed; } catch { } + + var renderer = RendererMain.Instance; + bool islandHover = renderer?.MainIsland?.IsHovering == true; + bool inputActive = leftMouseDown || + now <= _renderBurstUntil || + (islandHover && (now - _lastMouseMoveTime) <= _idleMouseThreshold); + bool wantsRealtime = renderer?.WantsRealtimeRendering == true; + bool wantsContinuous = renderer?.WantsContinuousRendering == true; + + if (inputActive || wantsRealtime) { - bool islandHover = false; - try - { - islandHover = RendererMain.Instance?.MainIsland?.IsHovering ?? false; - } - catch { } - - bool idle = !islandHover || - ((now - _lastMouseMoveTime) > _idleMouseThreshold); - - if (idle) - targetHz = 60; + targetHz = fullRefreshHz; } - - desiredInterval = TimeSpan.FromMilliseconds(1000.0 / targetHz); + else if (wantsContinuous) + { + targetHz = Math.Min(ContinuousRenderHz, fullRefreshHz); + } + else + { + targetHz = Math.Min(StaticIdleRenderHz, fullRefreshHz); + } + } + else + { + targetHz = fullRefreshHz; } - _targetElapsedTime = desiredInterval; + if (!Settings.ToggleHighRefreshRate) + targetHz = Math.Min(targetHz, 60); + + targetHz = Math.Max(1, targetHz); + _targetElapsedTime = TimeSpan.FromMilliseconds(1000.0 / targetHz); + + if (targetHz >= displayRefresh) + { + _lastRenderTime = now; + onMainFormRender?.Invoke(); + return; + } } catch { } - var currentTime = DateTime.Now; - if (currentTime - _lastRenderTime >= _targetElapsedTime) + if (now - _lastRenderTime >= _targetElapsedTime) { - _lastRenderTime = currentTime; + _lastRenderTime = now; onMainFormRender?.Invoke(); } } + private void RefreshCachedRefreshRate(DateTime now, bool force = false) + { + if (!force && (now - _lastRefreshRateCheck) < _refreshRatePollInterval) + return; + + try + { + int refresh = DisplayHelper.GetRefreshRate(Settings.ScreenIndex); + if (refresh > 0) _cachedRefreshRate = refresh; + } + catch + { + if (_cachedRefreshRate <= 0) _cachedRefreshRate = 60; + } + + _lastRefreshRateCheck = now; + } + public bool isDragging = false; public void OnScroll(object? sender, System.Windows.Input.MouseWheelEventArgs e) @@ -281,8 +354,7 @@ public void AddRenderer() this.Content = parent; - // Ensure the new renderer is called from the centralised, throttled MainForm loop - onMainFormRender += customControl.Frame; + // RendererMain registers itself with the centralised, throttled MainForm loop. } // Allow external modules to pause/resume the rendering loop during suspend/hibernate @@ -294,7 +366,7 @@ public void PauseRendering() public void ResumeRendering() { _renderPaused = false; - _lastRenderTime = DateTime.Now; // Reset timing to avoid immediate large update + _lastRenderTime = DateTime.UtcNow; // Reset timing to avoid immediate large update } public void MainForm_DragEnter(object? sender, DragEventArgs e) @@ -436,4 +508,4 @@ private void TrayIcon_MouseUp(object? sender, Forms.MouseEventArgs e) } } } -} \ No newline at end of file +} diff --git a/DynamicWin/Main/RendererMain.cs b/DynamicWin/Main/RendererMain.cs index afeb500..8915d69 100644 --- a/DynamicWin/Main/RendererMain.cs +++ b/DynamicWin/Main/RendererMain.cs @@ -28,8 +28,47 @@ public class RendererMain : SKElement private bool lastIslandShadowSetting = Settings.ToggleIslandShadow; private bool lastShadowState = false; // Tracks whether shadow was active last frame - public static Vec2 ScreenDimensions => new Vec2(MainForm.Instance.Width, MainForm.Instance.Height); - public static Vec2 CursorPosition => new Vec2(Mouse.GetPosition(MainForm.Instance).X, Mouse.GetPosition(MainForm.Instance).Y); + public static Vec2 ScreenDimensions + { + get + { + if (instance != null && instance.inputSnapshotValid) + return instance.cachedScreenDimensions; + + var active = System.Windows.Application.Current.Windows.Cast().FirstOrDefault(w => w.IsActive && w.IsVisible); + if (active != null) return new Vec2((float)active.Width, (float)active.Height); + return new Vec2((float)MainForm.Instance.Width, (float)MainForm.Instance.Height); + } + } + + public static Vec2 CursorPosition + { + get + { + if (instance != null && instance.inputSnapshotValid) + return instance.cachedCursorPosition; + + var active = System.Windows.Application.Current.Windows.Cast().FirstOrDefault(w => w.IsActive && w.IsVisible); + if (active != null) + { + var pos = Mouse.GetPosition(active); + return new Vec2((float)pos.X, (float)pos.Y); + } + var mainPos = Mouse.GetPosition(MainForm.Instance); + return new Vec2((float)mainPos.X, (float)mainPos.Y); + } + } + + public static bool IsLeftMouseButtonDown + { + get + { + if (instance != null && instance.inputSnapshotValid) + return instance.cachedLeftMouseDown; + + return Mouse.LeftButton == MouseButtonState.Pressed; + } + } private static RendererMain? instance; public static RendererMain? Instance => instance; @@ -47,12 +86,22 @@ public class RendererMain : SKElement private Stopwatch? updateStopwatch; private int initialScreenBrightness = 0; + private DateTime lastBrightnessPoll = DateTime.MinValue; + private readonly TimeSpan brightnessPollInterval = TimeSpan.FromMilliseconds(500); private float deltaTime = 0f; public float DeltaTime => deltaTime; private bool isInitialized = false; public int canvasWithoutClip; + private bool inputSnapshotValid; + private Vec2 cachedCursorPosition = Vec2.zero; + private Vec2 cachedScreenDimensions = Vec2.zero; + private bool cachedLeftMouseDown; + + public bool WantsRealtimeRendering { get; private set; } = true; + public bool WantsContinuousRendering { get; private set; } = true; + public RendererMain() { MenuManager m = new MenuManager(); @@ -71,10 +120,6 @@ public RendererMain() MainForm.Instance.Drop += MainForm.Instance.OnDrop; MainForm.Instance.MouseWheel += MainForm.Instance.OnScroll; - // Get refresh rate via centralized helper - int refreshRate = DisplayHelper.GetRefreshRate(); - Debug.WriteLine($"Monitor Refresh Rate: {refreshRate} Hz"); - // Register to MainForm's centrally throttled render callback instead of subscribing directly to CompositionTarget.Rendering. MainForm.Instance.onMainFormRender += Frame; @@ -219,6 +264,8 @@ private void OnKeyRegistered(Keys key, KeyModifier modifier) private void Update() { + CaptureInputSnapshot(); + if (updateStopwatch != null) { updateStopwatch.Stop(); @@ -233,17 +280,23 @@ private void Update() onUpdate?.Invoke(DeltaTime); - if (BrightnessAdjustMenu.GetBrightness() != initialScreenBrightness && PopupOptions.saveData.brightnessPopup) + if (PopupOptions.saveData.brightnessPopup && (DateTime.UtcNow - lastBrightnessPoll) >= brightnessPollInterval) { - initialScreenBrightness = BrightnessAdjustMenu.GetBrightness(); - if (MenuManager.Instance.ActiveMenu is HomeMenu) - { - MenuManager.OpenOverlayMenu(new BrightnessAdjustMenu()); - } - else if (BrightnessAdjustMenu.timerUntilClose != null) + lastBrightnessPoll = DateTime.UtcNow; + + int currentBrightness = BrightnessAdjustMenu.GetBrightness(); + if (currentBrightness != initialScreenBrightness) { - BrightnessAdjustMenu.PressBK(); - BrightnessAdjustMenu.timerUntilClose = 0f; + initialScreenBrightness = currentBrightness; + if (MenuManager.Instance.ActiveMenu is HomeMenu) + { + MenuManager.OpenOverlayMenu(new BrightnessAdjustMenu()); + } + else if (BrightnessAdjustMenu.timerUntilClose != null) + { + BrightnessAdjustMenu.PressBK(); + BrightnessAdjustMenu.timerUntilClose = 0f; + } } } @@ -298,7 +351,11 @@ private void Update() // Update island shadow to follow island islandShadow?.UpdateCall(DeltaTime); - if (MainIsland.hidden) return; + if (MainIsland.hidden) + { + UpdateRenderDemand(); + return; + } // Take a stable snapshot of the menu object list to avoid InvalidOperationException var uiObjectsSnapshot = objects?.ToArray(); @@ -311,6 +368,77 @@ private void Update() uiObject.UpdateCall(DeltaTime); } } + + UpdateRenderDemand(); + } + + private void CaptureInputSnapshot() + { + try + { + var active = System.Windows.Application.Current.Windows.Cast().FirstOrDefault(w => w.IsActive && w.IsVisible) + ?? MainForm.Instance; + + var pos = Mouse.GetPosition(active); + cachedCursorPosition = new Vec2((float)pos.X, (float)pos.Y); + + double width = active.ActualWidth > 0 ? active.ActualWidth : active.Width; + double height = active.ActualHeight > 0 ? active.ActualHeight : active.Height; + cachedScreenDimensions = new Vec2((float)width, (float)height); + cachedLeftMouseDown = Mouse.LeftButton == MouseButtonState.Pressed; + inputSnapshotValid = true; + } + catch + { + inputSnapshotValid = false; + cachedLeftMouseDown = Mouse.LeftButton == MouseButtonState.Pressed; + } + } + + private void UpdateRenderDemand() + { + bool realtime = false; + bool continuous = false; + + try + { + realtime |= MenuManager.Instance?.IsAnimating == true; + realtime |= islandObject.SubtreeWantsRealtimeUpdate(); + continuous |= islandObject.SubtreeWantsContinuousUpdate(); + + if (islandShadow != null) + { + realtime |= islandShadow.SubtreeWantsRealtimeUpdate(); + continuous |= islandShadow.SubtreeWantsContinuousUpdate(); + } + + var activeObjects = objects; + if (activeObjects != null) + { + for (int i = 0; i < activeObjects.Count; i++) + { + var obj = activeObjects[i]; + if (obj == null) continue; + + if (!realtime && obj.SubtreeWantsRealtimeUpdate()) + realtime = true; + + if (!continuous && obj.SubtreeWantsContinuousUpdate()) + continuous = true; + + if (realtime && continuous) + break; + } + } + } + catch + { + realtime = true; + continuous = true; + } + + WantsRealtimeRendering = realtime; + WantsContinuousRendering = continuous || realtime; } protected override void OnPaintSurface(SKPaintSurfaceEventArgs e) @@ -444,4 +572,4 @@ private void Mask(SKCanvas canvas) canvas.ClipPath(path, SKClipOperation.Intersect, Settings.AntiAliasing); } } -} \ No newline at end of file +} diff --git a/DynamicWin/Main/Settings.cs b/DynamicWin/Main/Settings.cs index e9a1f5f..4ed7f2f 100644 --- a/DynamicWin/Main/Settings.cs +++ b/DynamicWin/Main/Settings.cs @@ -28,6 +28,8 @@ public class Settings private static int activeScreenIndex; private static int releaseStream; private static bool allowAutomaticUpdates = true; + private static bool alwaysTopmost; + private static bool reduceWorkingArea; public static IslandObject.IslandMode IslandMode { get => islandMode; set => islandMode = value; } public static bool AllowBlur { get => allowBlur; set => allowBlur = value; } @@ -42,6 +44,61 @@ public class Settings public static int ScreenIndex { get => activeScreenIndex; set => activeScreenIndex = value; } public static int ReleaseStream { get => releaseStream; set => releaseStream = value; } public static bool AllowAutomaticUpdates { get => allowAutomaticUpdates; set => allowAutomaticUpdates = value; } + public static bool AlwaysTopmost + { + get => alwaysTopmost; + set + { + alwaysTopmost = value; + + try + { + if (Application.Current != null) + { + Application.Current.Dispatcher.BeginInvoke(new Action(() => + { + try + { + if (MainForm.Instance != null) + WindowPositionHelper.CenterWindowOnMonitor(MainForm.Instance, ScreenIndex); + } + catch { } + })); + } + else + { + if (MainForm.Instance != null) + WindowPositionHelper.CenterWindowOnMonitor(MainForm.Instance, ScreenIndex); + } + } + catch { } + } + } + public static bool ReduceWorkingArea + { + get => reduceWorkingArea; + set + { + reduceWorkingArea = value; + try + { + if (Application.Current != null) + { + Application.Current.Dispatcher.BeginInvoke(new Action(() => + { + try + { + if (MainForm.Instance != null) MainForm.Instance.UpdateWindowConfiguration(); + } + catch { } + })); + } + else + { if (MainForm.Instance != null) MainForm.Instance.UpdateWindowConfiguration(); } + } + catch { } + } + } public static List smallWidgetsLeft; public static List smallWidgetsRight; @@ -77,6 +134,10 @@ public static void InitializeSettings() AllowAutomaticUpdates = SaveManager.Contains("settings.AllowAutomaticUpdates") ? (bool)SaveManager.Get("settings.AllowAutomaticUpdates") : true; + AlwaysTopmost = SaveManager.Contains("settings.AlwaysTopmost") ? (bool)SaveManager.Get("settings.AlwaysTopmost") : true; + + ReduceWorkingArea = SaveManager.Contains("settings.ReduceWorkingArea") ? (bool)SaveManager.Get("settings.ReduceWorkingArea") : true; + Theme = (int)((Int64)SaveManager.Get("settings.theme")); ScreenIndex = (int)((Int64)SaveManager.Get("settings.screenindex")); ReleaseStream = SaveManager.Contains("settings.ReleaseStream") ? (int)((Int64)SaveManager.Get("settings.ReleaseStream")) : 0; @@ -137,13 +198,14 @@ public static void InitializeSettings() // default automatic updates enabled AllowAutomaticUpdates = true; + AlwaysTopmost = true; + ReduceWorkingArea = true; Theme = 0; SaveManager.SaveData.Add("settings", 1); } - // This must be run after loading all settings AfterSettingsLoaded(); }catch(Exception e) @@ -186,6 +248,8 @@ public static void Save() SaveManager.Add("settings.ReleaseStream", ReleaseStream); SaveManager.Add("settings.AllowAutomaticUpdates", AllowAutomaticUpdates); + SaveManager.Add("settings.AlwaysTopmost", AlwaysTopmost); + SaveManager.Add("settings.ReduceWorkingArea", ReduceWorkingArea); SaveManager.Add("settings.theme", Theme); SaveManager.Add("settings.screenindex", ScreenIndex); diff --git a/DynamicWin/Resources/Res.cs b/DynamicWin/Resources/Res.cs index d1c0a88..1d219f8 100644 --- a/DynamicWin/Resources/Res.cs +++ b/DynamicWin/Resources/Res.cs @@ -9,9 +9,13 @@ namespace DynamicWin.Resources { public class Res { - public static SKTypeface SFProRegular { get => LoadTypeface("Resources\\SF-Pro-Display-Regular.otf"); } - public static SKTypeface SFProBold { get => LoadTypeface("Resources\\SF-Pro-Display-Bold.otf"); } - public static SKTypeface CascadiaMono { get => LoadTypeface("Resources\\CascadiaMono.ttf"); } + private static SKTypeface? sfProRegular; + private static SKTypeface? sfProBold; + private static SKTypeface? cascadiaMono; + + public static SKTypeface SFProRegular => sfProRegular ??= LoadTypeface("Resources\\SF-Pro-Display-Regular.otf"); + public static SKTypeface SFProBold => sfProBold ??= LoadTypeface("Resources\\SF-Pro-Display-Bold.otf"); + public static SKTypeface CascadiaMono => cascadiaMono ??= LoadTypeface("Resources\\CascadiaMono.ttf"); public static SKBitmap searchIcon; public static SKBitmap editIcon; @@ -209,7 +213,7 @@ public static SKBitmap LoadImg(string path) { using (var stream = File.OpenRead("Resources\\icons\\" + path)) { - var image = SKImage.FromEncodedData(stream); + using var image = SKImage.FromEncodedData(stream); return SKBitmap.FromImage(image); } } @@ -228,7 +232,7 @@ public static SKTypeface LoadTypeface(string path) }catch(Exception e) { System.Diagnostics.Debug.WriteLine("Could not load font: " + path); - return SFProRegular; + return SKTypeface.Default; } } } diff --git a/DynamicWin/UI/Menu/MenuManager.cs b/DynamicWin/UI/Menu/MenuManager.cs index a9c21c4..623ad76 100644 --- a/DynamicWin/UI/Menu/MenuManager.cs +++ b/DynamicWin/UI/Menu/MenuManager.cs @@ -181,6 +181,7 @@ private void SetOverlay(BaseMenu overlayMenu, float duration, BaseMenu menuToOpe static List menuLoadQueue = new List(); Animator menuAnimatorOut; + public bool IsAnimating => (menuAnimatorOut != null && menuAnimatorOut.IsRunning) || menuLoadQueue.Count > 0; public void Update(float deltaTime) { diff --git a/DynamicWin/UI/Menu/Menus/SettingsMenu.cs b/DynamicWin/UI/Menu/Menus/SettingsMenu.cs index 5f5c297..621a078 100644 --- a/DynamicWin/UI/Menu/Menus/SettingsMenu.cs +++ b/DynamicWin/UI/Menu/Menus/SettingsMenu.cs @@ -17,6 +17,7 @@ using System.Text; using System.Threading.Tasks; using System.Windows.Controls; +using System.Windows.Forms; using System.Windows.Input; using System.Windows.Navigation; using System.Xml.Linq; @@ -28,13 +29,18 @@ namespace DynamicWin.UI.Menu.Menus public class SettingsMenu : BaseMenu { private static List _cachedCustomOptions; + private readonly Action scrollHandler; public SettingsMenu() { - MainForm.onScrollEvent += (MouseWheelEventArgs x) => - { - yScrollOffset += x.Delta * 0.50f; - }; + scrollHandler = OnScroll; + MainForm.onScrollEvent += scrollHandler; + } + + private void OnScroll(MouseWheelEventArgs x) + { + if (!ReferenceEquals(MenuManager.Instance?.ActiveMenu, this)) return; + yScrollOffset += x.Delta * 0.50f; } bool changedTheme = false; @@ -52,6 +58,8 @@ void SaveAndBack() Settings.ToggleHomeMenuShadow = toggleHomeMenuShadow.IsChecked; Settings.RunOnStartup = runOnStartup.IsChecked; Settings.AllowAutomaticUpdates = allowAutomaticUpdates.IsChecked; + Settings.AlwaysTopmost = alwaysTopmost.IsChecked; + Settings.ReduceWorkingArea = reduceWorkingArea.IsChecked; // Save the selected default big menu mode if (bigMenuModeSelector != null) @@ -88,12 +96,14 @@ void SaveAndBack() DWCheckbox antiAliasing; DWCheckbox runOnStartup; DWCheckbox allowAutomaticUpdates; + DWCheckbox alwaysTopmost; + DWCheckbox reduceWorkingArea; DWCheckbox toggleIslandShadow; DWCheckbox toggleHomeMenuShadow; DWCheckbox toggleHighRefreshRate; DWCheckbox limitRefreshRateWhenIdle; - DWText refreshRateDisclaimer1, refreshRateDisclaimer2, limitRefreshRateDisclaimer1, limitRefreshRateDisclaimer2; + DWText refreshRateDisclaimer1, refreshRateDisclaimer2, limitRefreshRateDisclaimer1, limitRefreshRateDisclaimer2, topmostDisclaimer, topmostDisclaimer2, workingAreaDisclaimer; UIObject bottomMask; @@ -132,6 +142,36 @@ public override List InitializeMenu(IslandObject island) objects.Add(islandMode); } + topmostDisclaimer = new DWText(island, "Renders the interface to its minimum height and width possible, also improves performance.", new Vec2(25, 0), UIAlignment.TopLeft); + topmostDisclaimer.Font = Res.SFProRegular; + topmostDisclaimer.TextSize = 12; + topmostDisclaimer.Anchor.X = 0; + + topmostDisclaimer2 = new DWText(island, "Disabling this setting may prevent the interface from being placed correctly at the top.", new Vec2(25, 0), UIAlignment.TopLeft); + topmostDisclaimer2.Font = Res.SFProRegular; + topmostDisclaimer2.TextSize = 12; + topmostDisclaimer2.Anchor.X = 0; + + objects.Add(topmostDisclaimer); + objects.Add(topmostDisclaimer2); + + alwaysTopmost = new DWCheckbox(island, "Keep interface always topmost", new Vec2(25, 0), new Vec2(25, 25), () => { }, UIAlignment.TopLeft); + alwaysTopmost.IsChecked = Settings.AlwaysTopmost; + alwaysTopmost.Anchor.X = 0; + objects.Add(alwaysTopmost); + + workingAreaDisclaimer = new DWText(island, "Prevents the interface from overlapping on top of other windows.", new Vec2(25, 0), UIAlignment.TopLeft); + workingAreaDisclaimer.TextSize = 12; + workingAreaDisclaimer.Font = Res.SFProRegular; + workingAreaDisclaimer.Anchor.X = 0; + + reduceWorkingArea = new DWCheckbox(island, "Reduce working area", new Vec2(25, 0), new Vec2(25, 25), () => { }, UIAlignment.TopLeft); + reduceWorkingArea.IsChecked = Settings.ReduceWorkingArea; + reduceWorkingArea.Anchor.X = 0; + + objects.Add(workingAreaDisclaimer); + objects.Add(reduceWorkingArea); + allowBlur = new DWCheckbox(island, "Toggle blur", new Vec2(25, 0), new Vec2(25, 25), () => { }, UIAlignment.TopLeft); allowBlur.IsChecked = Settings.AllowBlur; allowBlur.Anchor.X = 0; @@ -575,6 +615,11 @@ public override List InitializeMenu(IslandObject island) float yScrollOffset = 0f; float ySmoothScroll = 0f; + float cachedScrollLimit = 0f; + float lastLayoutScroll = float.NaN; + float lastBigWidgetAdderHeight = float.NaN; + float lastSmallWidgetAdderWidth = float.NaN; + int lastLayoutObjectCount = -1; public override void Update() { @@ -585,23 +630,45 @@ public override void Update() bottomMask.blurAmount = 15; - var yScrollLim = 0f; - var yPos = 35f; - var spacing = 15f; + bool layoutDirty = + float.IsNaN(lastLayoutScroll) || + Math.Abs(ySmoothScroll - lastLayoutScroll) > 0.05f || + lastLayoutObjectCount != UiObjects.Count || + (bigWidgetAdder != null && Math.Abs(bigWidgetAdder.Size.Y - lastBigWidgetAdderHeight) > 0.05f) || + (smallWidgetAdder != null && Math.Abs(smallWidgetAdder.Size.X - lastSmallWidgetAdderWidth) > 0.05f); - for (int i = 0; i < UiObjects.Count - 2; i++) + if (layoutDirty) { - var uiObject = UiObjects[i]; - if (!uiObject.IsEnabled) continue; + var yScrollLim = 0f; + var yPos = 35f; + var spacing = 15f; - uiObject.LocalPosition.Y = yPos + ySmoothScroll; - yPos += uiObject.Size.Y + spacing; + for (int i = 0; i < UiObjects.Count - 2; i++) + { + var uiObject = UiObjects[i]; + if (!uiObject.IsEnabled) continue; - if (yPos > IslandSize().Y - 50) yScrollLim += uiObject.Size.Y + spacing; + uiObject.LocalPosition.Y = yPos + ySmoothScroll; + yPos += uiObject.Size.Y + spacing; + + if (yPos > IslandSize().Y - 50) yScrollLim += uiObject.Size.Y + spacing; + } + + cachedScrollLimit = yScrollLim; + lastLayoutScroll = ySmoothScroll; + lastLayoutObjectCount = UiObjects.Count; + lastBigWidgetAdderHeight = bigWidgetAdder?.Size.Y ?? 0f; + lastSmallWidgetAdderWidth = smallWidgetAdder?.Size.X ?? 0f; } yScrollOffset = Mathf.Lerp(yScrollOffset, - Mathf.Clamp(yScrollOffset, -yScrollLim, 0f), 15f * RendererMain.Instance.DeltaTime); + Mathf.Clamp(yScrollOffset, -cachedScrollLimit, 0f), 15f * RendererMain.Instance.DeltaTime); + } + + public override void OnDispose() + { + MainForm.onScrollEvent -= scrollHandler; + base.OnDispose(); } public override Vec2 IslandSize() @@ -677,4 +744,4 @@ public override Col IslandBorderColor() else return new Col(0, 0, 0, 0); // Render transparent if island mode is Notch } } -} \ No newline at end of file +} diff --git a/DynamicWin/UI/UIElements/Custom/DWProgressBarEx.cs b/DynamicWin/UI/UIElements/Custom/DWProgressBarEx.cs index 86715f0..ef0178d 100644 --- a/DynamicWin/UI/UIElements/Custom/DWProgressBarEx.cs +++ b/DynamicWin/UI/UIElements/Custom/DWProgressBarEx.cs @@ -96,10 +96,10 @@ public override void Update(float deltaTime) } } + public override bool WantsRealtimeUpdate => Math.Abs(displayedValue - Value) > 0.001f; + public override void Draw(SKCanvas canvas) { - var paint = GetPaint(); - // Compute screen rect for the control var size = Size; var pos = RawPosition + LocalPosition; diff --git a/DynamicWin/UI/UIElements/Custom/MediaPlayer.cs b/DynamicWin/UI/UIElements/Custom/MediaPlayer.cs index 2d0aeb8..7ebc8b0 100644 --- a/DynamicWin/UI/UIElements/Custom/MediaPlayer.cs +++ b/DynamicWin/UI/UIElements/Custom/MediaPlayer.cs @@ -3,8 +3,8 @@ using DynamicWin.UI.Menu.Menus; using DynamicWin.Utils; using SkiaSharp; -using System.Diagnostics; -using System.IO; +using System.Threading; +using System.Threading.Tasks; using Windows.Media.Control; /* @@ -16,7 +16,7 @@ * Author: 59xa * GitHub: https://github.com/59xa * Implementation Date: 26 December 2025 - * Last Modified: 15 February 2026 + * Last Modified: 31 May 2026 * */ @@ -24,128 +24,151 @@ namespace DynamicWin.UI.UIElements.Custom { public class MediaPlayer : UIObject { - private CancellationTokenSource? cts; - private DynamicWin.Utils.Media? currentMedia; - // Use SKImage for owned renderable images to avoid drawing shared/disposed SKBitmap - private SKImage? thumbnailImage; // Currently cached decoded image (owned by this object) - private ulong? thumbnailFingerprint; // Cached fingerprint for image (computed from a temporary SKBitmap during decode) - private SKImage? pendingImage; // Newly decoded image waiting to animate in - private ulong? pendingFingerprint; - private DynamicWin.Utils.Media? pendingMedia; // Pending metadata object - private readonly object mediaLock = new object(); - // Guard to ensure only one decode loop runs at a time - private int thumbnailDecodeRunning = 0; - // How often the background loop waits between iterations (cooperative wait broken into steps) - private TimeSpan fetchInterval = TimeSpan.FromMilliseconds(250); // faster timeline updates - - // Rate-limited service bytes and flags to make thumbnail processing - private volatile byte[]? pendingThumbnailBytesFromService = null; // bytes handed to us by service events - private volatile bool mediaNeedsUpdate = false; // set by service event when thumbnail changed - private DateTime lastMediaCheck = DateTime.MinValue; - private TimeSpan mediaCheckInterval = TimeSpan.FromSeconds(2); // only decode/check media every 2s - // Debounce short-lived 'no media' signals to avoid flicker when service emits transient nulls - private DateTime mediaClearRequestedAt = DateTime.MinValue; - private readonly TimeSpan mediaClearDelay = TimeSpan.FromSeconds(1); + private const float TitleTextSize = 14f; + private const float ArtistTextSize = 12f; + private const float TimelineTextSize = 10f; + private const float TimelineHeight = 6f; + private const float TimelineSidePadding = 40f; + private const float TimelineBarPadding = 12f; + private const float TitleScrollSpeed = 30f; + private const float TitleScrollDelay = 1f; + private const float ThumbnailAnimSpeed = 8f; + private const int TitleTruncateChars = 35; + private const int ArtistTruncateChars = 45; - // Keys to detect duplicates - private string? currentMediaKey; - private string? pendingMediaKey; - - // Scrolling title state - private float titleScrollOffset = 0f; // Current scroll position - private float titleScrollSpeed = 30f; // Pixels per second - private float titleScrollDelay = 1f; // Seconds to pause before scrolling - private float titleScrollTimer = 0f; // Timer for delay - private bool isTitleScrolling = false; - private string? fullTitleText = null; - private float titleTextWidth = 0f; - private const int titleScrollCharThreshold = 35; - - // Animation state handled by MediaAnimator + private readonly object mediaLock = new object(); + private readonly MediaController controller; private readonly MediaAnimator animator = new MediaAnimator(); - private SKImage? previousImage = null; // Image that is being replaced - - // Playback controls and progress - private MediaController controller; - private DWImageButton? btnPrev; - private DWImageButton? btnPlay; - private DWImageButton? btnNext; - - AudioVisualiser visualiser; - // Timeline state - private TimeSpan? timelinePosition; - private TimeSpan? timelineDuration; - private bool isPlayingFlag = false; - - // Optimistic toggle to update UI immediately when user presses play/pause - private bool optimisticState = false; - private bool optimisticActive = false; // Remains active until a timeline sample updates - - // Animated progress fill - private float displayFill = 0f; - - private float timelineHeight = 6f; // Thickness of the bar - private readonly SKColor timelineBgColor; // subtle background - private Col timelineFgColor = Theme.TextMain; // active fill - private float timelineBarPadding = 12f; // vertical padding below buttons - private float timelineSidePadding = 40f; // space on left/right for timeline text - private Col timelineTextColor = Theme.TextMain.Override(a: 55); - private float timelineTextSize = 10f; - private DWProgressBarEx? timelineBar; - - // Add lastSampleKey to detect new samples - private string? lastSampleKey = null; - - // Latest timeline sample (elapsed since start) and timestamp when it was received - private TimeSpan? lastSampleElapsed = null; - private TimeSpan? lastSampleDuration = null; + private readonly DWImageButton btnPrev; + private readonly DWImageButton btnPlay; + private readonly DWImageButton btnNext; + private readonly AudioVisualiser visualiser; + + private readonly SKPaint thumbnailPaint; + private readonly SKPaint placeholderPaint; + private readonly SKPaint dimPaint; + private readonly SKPaint titlePaint; + private readonly SKPaint artistPaint; + private readonly SKPaint timelineTextPaint; + private readonly SKPaint timelineTrackPaint; + private readonly SKPaint timelineFillPaint; + + private readonly SKFont titleFont; + private readonly SKFont artistFont; + private readonly SKFont timelineFont; + + private SKImage? thumbnailImage; + private ulong? thumbnailFingerprint; + private SKImage? pendingImage; + private ulong? pendingFingerprint; + private SKImage? previousImage; + private DynamicWin.Utils.Media? currentMedia; + private DynamicWin.Utils.Media? pendingMedia; + private string currentMediaKey = string.Empty; + private string pendingMediaKey = string.Empty; + private int thumbnailDecodeVersion; + private DateTime mediaClearRequestedAt = DateTime.MinValue; + private readonly TimeSpan mediaClearDelay = TimeSpan.FromSeconds(1); + private DateTime lastMissingThumbnailFetch = DateTime.MinValue; + private readonly TimeSpan missingThumbnailFetchInterval = TimeSpan.FromSeconds(1); + private readonly TimeSpan optimisticStatusGrace = TimeSpan.FromMilliseconds(900); + private readonly TimeSpan timelineSeekConfirmationWindow = TimeSpan.FromMilliseconds(1200); + private const double TimelineSeekMatchToleranceSeconds = 2.0; + private const double TimelineSampleJitterToleranceSeconds = 0.35; + private const double TimelineVisualSnapSeconds = 2.0; + + private CancellationTokenSource? timelineCts; + private int timelineFetchRunning; + private int timelineRefreshPending; + private readonly TimeSpan timelineFetchInterval = TimeSpan.FromSeconds(2); + + private TimeSpan? lastSampleElapsed; + private TimeSpan? lastSampleDuration; private DateTime lastSampleReceivedAt = DateTime.MinValue; private GlobalSystemMediaTransportControlsSessionPlaybackStatus lastPlaybackStatus = GlobalSystemMediaTransportControlsSessionPlaybackStatus.Closed; + private MediaTimeline? currentTimeline; - // Keep the latest MediaTimeline for the current session (metadata kept in currentMedia) - private MediaTimeline? currentTimeline = null; - - // Only fetch timeline once per media change; let local clock advance between fetches to avoid jitter - private bool timelineFetchedOnce = false; - - private DateTime lastTimelineResync = DateTime.MinValue; - - // How often to re-fetch the timeline from MediaInfo - private TimeSpan timelineFetchInterval = TimeSpan.FromSeconds(4); - - // If the user is interacting with the timeline (seeking), set this to true and update userSeekElapsed - private bool userIsSeeking = false; + private TimeSpan? pendingSeekElapsed; + private DateTime pendingSeekStartedAt = DateTime.MinValue; + private DateTime pendingSeekUntil = DateTime.MinValue; + private TimeSpan? timelinePosition; + private TimeSpan? timelineDuration; + private bool isPlayingFlag; + private bool optimisticState; + private bool optimisticActive; + private DateTime optimisticStartedAt = DateTime.MinValue; + private bool userIsSeeking; private TimeSpan userSeekElapsed = TimeSpan.Zero; - private bool mouseDownOverTimeline = false; - // Whether the cursor is hovering over the timeline bar (used to increase bar height) - private bool isHoveringOverTimeline = false; - - // Smoothed displayed elapsed seconds to avoid integer-second jitter in the UI text - private float displayedElapsedSeconds = 0f; - private bool displayedElapsedInitialized = false; - // Extra height applied to timeline when hovering/seeking (smoothed) - private float timelineExtraHeight = 0f; - - // Track whether we are subscribed to the thumbnail service so we can unsubscribe when not enabled - private bool isThumbnailSubscribed = false; - - // Animation for thumbnail scale/dim - private float thumbnailAnim = 1f; // 1 = playing, 0 = paused - private const float thumbnailAnimSpeed = 8f; - - // Metadata fetch throttle to populate textual metadata when thumbnail exists but metadata not set - private int metadataFetchRunning = 0; - private DateTime lastMetadataFetch = DateTime.MinValue; - private readonly TimeSpan metadataFetchInterval = TimeSpan.FromSeconds(1); + private bool mouseDownOverTimeline; + private bool isHoveringOverTimeline; + private float displayFill; + private float displayedElapsedSeconds; + private bool displayedElapsedInitialized; + private float timelineExtraHeight; + private float thumbnailAnim = 1f; + + private bool isThumbnailSubscribed; + private bool isTimelineSubscribed; + private bool childrenAreActive; + private bool visualiserCaptureEnabled; + + private SKRect layoutRect = SKRect.Empty; + private SKRect thumbnailRect = SKRect.Empty; + private SKRect localThumbnailRect = SKRect.Empty; + private SKRect titleClipRect = SKRect.Empty; + private SKRect timelineBarRect = SKRect.Empty; + private SKRect timelineBaseRect = SKRect.Empty; + private SKPath? thumbnailPath; + private SKPath? localThumbnailPath; + private float textX; + private float titleBaseline; + private float artistBaseline; + private float timelineTextBaseline; + private float timelineLeftX; + private float timelineRightX; + private float timelineBarBaseY; + private float layoutTimelineExtraHeight = -1f; + private float layoutTimelineRightTextWidth = -1f; + private bool layoutDirty = true; + + private string fullTitleText = "No media playing"; + private string truncatedTitleText = "No media playing"; + private string artistText = "No media playing"; + private float titleTextWidth; + private float titleScrollOffset; + private float titleScrollTimer; + private bool isTitleScrolling; + private SKTextBlob? fullTitleBlob; + private SKTextBlob? truncatedTitleBlob; + private SKTextBlob? artistBlob; + + private string cachedTimelineLeftText = "--:--"; + private string cachedTimelineRightText = "--:--"; + private float cachedTimelineRightTextWidth; + private int cachedTimelineElapsedSecond = int.MinValue; + private int cachedTimelineDurationSecond = int.MinValue; + private SKTextBlob? timelineLeftBlob; + private SKTextBlob? timelineRightBlob; public MediaPlayer(UIObject? parent, Vec2 position, Vec2 size, UIAlignment alignment = UIAlignment.TopCenter) : base(parent, position, size, alignment) { - timelineBgColor = GetColor(Theme.WidgetBackground.Override(a: 200)).Value(); controller = new MediaController(); - // Create interactive playback buttons and progress UI as local objects; will be positioned in Update - btnPrev = new DWImageButton(this, Res.Previous, new Vec2(0, 0), new Vec2(28, 28), () => { controller.Previous(); }, alignment: UIAlignment.TopLeft) + titleFont = new SKFont(Res.SFProBold, TitleTextSize); + artistFont = new SKFont(Res.SFProRegular, ArtistTextSize); + timelineFont = new SKFont(Res.SFProRegular, TimelineTextSize); + + thumbnailPaint = CreateFillPaint(); + placeholderPaint = CreateFillPaint(); + dimPaint = CreateFillPaint(); + titlePaint = CreateTextPaint(Res.SFProBold, TitleTextSize); + artistPaint = CreateTextPaint(Res.SFProRegular, ArtistTextSize); + timelineTextPaint = CreateTextPaint(Res.SFProRegular, TimelineTextSize); + timelineTrackPaint = CreateFillPaint(); + timelineFillPaint = CreateFillPaint(); + + btnPrev = new DWImageButton(this, Res.Previous, new Vec2(0, 0), new Vec2(28, 28), () => controller.Previous(), UIAlignment.TopLeft) { roundRadius = 14f, normalColor = Col.Transparent, @@ -155,85 +178,7 @@ public MediaPlayer(UIObject? parent, Vec2 position, Vec2 size, UIAlignment align }; AddLocalObject(btnPrev); - // Hook play/pause button to also toggle optimistic UI state - btnPlay = new DWImageButton(this, Res.Play, new Vec2(0, 0), new Vec2(32, 32), () => { - // Determine desired action (play or pause) - bool currentlyPlaying = GetEffectivePlayingState(); - bool willPlay = !currentlyPlaying; - - optimisticState = willPlay; - optimisticActive = true; - - // Update icon immediately - if (btnPlay != null) - { - btnPlay.Image.Image = optimisticState ? (Res.Pause ?? Res.Stop) : Res.Play; - } - - // Try WinRT play/pause separately (Play vs Pause) and force a timeline refresh afterwards - _ = Task.Run(async () => - { - try - { - bool ok = await MediaInfo.TryTogglePlayPauseAsync().ConfigureAwait(false); - - if (!ok) - { - // Fallback toggle if specific call isn't supported - controller.PlayPause(); - } - - // Immediately update local timeline/playback state optimistically so UI responds fast - try - { - lock (mediaLock) - { - var now = DateTime.UtcNow; - if (willPlay) - { - // Resume: mark as playing and record reference time so virtual progression continues from lastSampleElapsed - if (!lastSampleElapsed.HasValue) - { - // If there's no sample, set to zero - lastSampleElapsed = TimeSpan.Zero; - } - lastSampleReceivedAt = now; - lastPlaybackStatus = GlobalSystemMediaTransportControlsSessionPlaybackStatus.Playing; - optimisticActive = false; - timelineFetchedOnce = true; - } - else - { - // Pause: capture current elapsed and mark paused so virtual progression stops - if (!lastSampleElapsed.HasValue) - { - lastSampleElapsed = TimeSpan.Zero; - } - - if (lastPlaybackStatus == GlobalSystemMediaTransportControlsSessionPlaybackStatus.Playing && lastSampleReceivedAt != DateTime.MinValue) - { - try - { - lastSampleElapsed = lastSampleElapsed.Value + (now - lastSampleReceivedAt); - } - catch { } - } - - lastSampleReceivedAt = now; - lastPlaybackStatus = GlobalSystemMediaTransportControlsSessionPlaybackStatus.Paused; - optimisticActive = false; - timelineFetchedOnce = true; - } - } - } - catch { } - } - catch - { - try { controller.PlayPause(); } catch { } - } - }); - }, alignment: UIAlignment.TopLeft) + btnPlay = new DWImageButton(this, Res.Play, new Vec2(0, 0), new Vec2(32, 32), HandlePlayPauseClick, UIAlignment.TopLeft) { roundRadius = 16f, normalColor = Col.Transparent, @@ -243,7 +188,7 @@ public MediaPlayer(UIObject? parent, Vec2 position, Vec2 size, UIAlignment align }; AddLocalObject(btnPlay); - btnNext = new DWImageButton(this, Res.Next, new Vec2(0, 0), new Vec2(28, 28), () => { controller.Next(); }, alignment: UIAlignment.TopLeft) + btnNext = new DWImageButton(this, Res.Next, new Vec2(0, 0), new Vec2(28, 28), () => controller.Next(), UIAlignment.TopLeft) { roundRadius = 14f, normalColor = Col.Transparent, @@ -257,1321 +202,1433 @@ public MediaPlayer(UIObject? parent, Vec2 position, Vec2 size, UIAlignment align { UseThumbnailBackground = true, EnableColourTransition = false, + ThumbnailBlurAmount = 5f, }; AddLocalObject(visualiser); + visualiser.SetCapturing(false); + visualiser.SetThumbnailSubscription(false); + visualiser.SilentSetActive(false); - // Timeline progress bar (created as local object; size/pos updated in Update) - try - { - timelineBar = new DWProgressBarEx(this, new Vec2(0, 0), new Vec2(200, timelineHeight), UIAlignment.TopCenter, - background: Theme.WidgetBackground.Override(a: 0.06f), foreground: timelineFgColor); - timelineBar.CornerRadius = timelineHeight / 2f; - timelineBar.Smoothing = 30f; - timelineBar.SetValueImmediate(0f); - AddLocalObject(timelineBar); - } - catch { timelineBar = null; } + ReplaceTextBlob(ref fullTitleBlob, fullTitleText, titleFont); + ReplaceTextBlob(ref truncatedTitleBlob, truncatedTitleText, titleFont); + ReplaceTextBlob(ref artistBlob, artistText, artistFont); + ReplaceTextBlob(ref timelineLeftBlob, cachedTimelineLeftText, timelineFont); + ReplaceTextBlob(ref timelineRightBlob, cachedTimelineRightText, timelineFont); - // Subscribe to central thumbnail service event - try + SetChildInteraction(false); + } + + protected override void OnActiveChanged(bool isEnabled) + { + base.OnActiveChanged(isEnabled); + + if (isEnabled) { - MediaThumbnailService.Instance.ThumbnailChanged += OnThumbnailChanged; - isThumbnailSubscribed = true; + visualiser.SetThumbnailSubscription(true); + SubscribeToThumbnailService(); + SubscribeToTimelineEvents(); + StartTimelineLoop(); + RequestTimelineRefresh(); } - catch { isThumbnailSubscribed = false; } - - // Try to initialise thumbnail from service cache so it doesn't disappear when re-opening - try + else { - var bytes = MediaThumbnailService.Instance.GetCurrentThumbnailBytes(); - if (bytes != null && bytes.Length > 0) - { - var img = MediaThumbnailUtils.DecodeBytesToImageAndFingerprint(bytes, out ulong? fp); - if (img != null) - { - lock (mediaLock) - { - thumbnailImage = img; - thumbnailFingerprint = fp; - } - } - } + SetChildInteraction(false); + SetVisualiserCapture(false); + visualiser.SetThumbnailSubscription(false); + StopTimelineLoop(); + UnsubscribeFromTimelineEvents(); + UnsubscribeFromThumbnailService(); + ResetMediaState(clearText: true); } - catch { } } - private bool GetEffectivePlayingState() + public override void Update(float deltaTime) { - // If optimistic is active, prefer that until timeline updates arrive - if (optimisticActive) return optimisticState; - return isPlayingFlag; + base.Update(deltaTime); + + bool visible = ShouldRenderMediaPlayer(); + bool becameVisible = visible && !childrenAreActive; + SetChildInteraction(visible); + if (!visible) return; + + StartTimelineLoop(); + if (becameVisible) + RequestTimelineRefresh(); + + ClearMediaAfterDebounce(); + + EnsureLayout(); + StepThumbnailAnimation(deltaTime); + StepThumbnailSwapAnimation(deltaTime); + HandleTimelineInput(); + UpdateTimelineSnapshot(); + UpdateDisplayFill(deltaTime); + UpdateButtonLayoutAndIcon(); + UpdateTextCache(deltaTime); + UpdateTimelineTextCache(); + UpdateVisualiserState(); } - protected override void OnActiveChanged(bool isEnabled) + public override void Draw(SKCanvas canvas) { - base.OnActiveChanged(isEnabled); + if (!ShouldRenderMediaPlayer()) return; - if (isEnabled) - { - // Re-subscribe to thumbnail service if needed - try - { - if (!isThumbnailSubscribed) - { - MediaThumbnailService.Instance.ThumbnailChanged += OnThumbnailChanged; - isThumbnailSubscribed = true; - } - } - catch { isThumbnailSubscribed = false; } + EnsureLayout(); - StartFetchLoop(); + SKImage? image; + bool hasMedia; + lock (mediaLock) + { + image = thumbnailImage; + hasMedia = currentMedia != null || thumbnailImage != null || pendingImage != null; + } - // If service has a cached bitmap bytes, ensure it's used (queue as pending to animate in) - var bytes = MediaThumbnailService.Instance.GetCurrentThumbnailBytes(); - if (bytes != null && bytes.Length > 0) - { - try - { - var img = MediaThumbnailUtils.DecodeBytesToImageAndFingerprint(bytes, out ulong? fp); - if (img != null) - { - lock (mediaLock) - { - if (thumbnailImage == null) - { - pendingImage = img; - pendingFingerprint = fp; - pendingMedia = null; - pendingMediaKey = null; - } - else - { - try { thumbnailImage.Dispose(); } catch { } - thumbnailImage = img; - thumbnailFingerprint = fp; - } - } - } - } - catch { } - } - else - { - // No cached service bitmap yet - do a one-shot fetch so first-open has a thumbnail. - Task.Run(async () => - { - try - { - var b = await MediaInfo.FetchCurrentThumbnailBytesAsync().ConfigureAwait(false); - var meta = await MediaInfo.FetchCurrentMediaAsync().ConfigureAwait(false); + DrawThumbnail(canvas, image); + DrawText(canvas); + DrawTimeline(canvas, hasMedia); + } - if (b != null && b.Length > 0) - { - var img = MediaThumbnailUtils.DecodeBytesToImageAndFingerprint(b, out ulong? fp); - if (img != null) - { - lock (mediaLock) - { - if (thumbnailImage != null && thumbnailFingerprint.HasValue && thumbnailFingerprint.Value == fp) - { - currentMedia = meta; - currentMediaKey = (meta == null) ? string.Empty : $"{meta.Title ?? ""}|{meta.Artist ?? ""}|{b.Length}"; - optimisticActive = false; - } - else - { - if (thumbnailImage == null && pendingImage == null) - { - pendingImage = img; - pendingFingerprint = fp; - pendingMedia = meta; - pendingMediaKey = (meta == null) ? string.Empty : $"{meta.Title ?? ""}|{meta.Artist ?? ""}|{b.Length}"; - } - else - { - if (pendingImage != null) { try { pendingImage.Dispose(); } catch { } } - pendingImage = img; - pendingFingerprint = fp; - pendingMedia = meta; - pendingMediaKey = (meta == null) ? string.Empty : $"{meta.Title ?? ""}|{meta.Artist ?? ""}|{b.Length}"; - } - } - } - } - } - else - { - // If no thumbnail bytes but metadata is available, adopt the metadata so title/artist and - // timeline information are shown immediately even when a thumbnail hasn't been provided. - // This prevents the UI from showing empty text while MediaController has already fetched metadata. - if (meta != null) - { - lock (mediaLock) - { - currentMedia = meta; - currentMediaKey = (meta == null) ? string.Empty : $"{meta.Title ?? ""}|{meta.Artist ?? ""}|0"; - optimisticActive = false; - } - } + public override void OnDestroy() + { + base.OnDestroy(); - if (meta == null) - { - lock (mediaLock) - { - if (thumbnailImage != null) { try { thumbnailImage.Dispose(); } catch { } thumbnailImage = null; thumbnailFingerprint = null; } - if (pendingImage != null) { try { pendingImage.Dispose(); } catch { } pendingImage = null; pendingFingerprint = null; } - if (previousImage != null) { try { previousImage.Dispose(); } catch { } previousImage = null; } - currentMediaKey = null; - pendingMediaKey = null; - currentMedia = null; - - optimisticActive = false; - } - } - } - } - catch { } - }); - } - } - else - { - // When disabled, stop background work but keep subscribed to the thumbnail service so - // we still receive any forced notifications when the user switches to Media view. - // This prevents missing a ForceNotifyCurrentThumbnail() call that may happen before - // the MediaPlayer becomes active - try - { - // Do not unsubscribe here; OnDestroy will unsubscribe to avoid leaks - } - catch { } + UnsubscribeFromThumbnailService(); + UnsubscribeFromTimelineEvents(); + StopTimelineLoop(); + visualiser.SetThumbnailSubscription(false); + ResetMediaState(clearText: true); + + thumbnailPaint.Dispose(); + placeholderPaint.Dispose(); + dimPaint.Dispose(); + titlePaint.Dispose(); + artistPaint.Dispose(); + timelineTextPaint.Dispose(); + timelineTrackPaint.Dispose(); + timelineFillPaint.Dispose(); + titleFont.Dispose(); + artistFont.Dispose(); + timelineFont.Dispose(); + thumbnailPath?.Dispose(); + localThumbnailPath?.Dispose(); + fullTitleBlob?.Dispose(); + truncatedTitleBlob?.Dispose(); + artistBlob?.Dispose(); + timelineLeftBlob?.Dispose(); + timelineRightBlob?.Dispose(); + } - // Stop the fetch loop and release pending resources (keep subscription) - StopFetchLoop(disposeCached: true); - // Reset thumbnail/animation state - ResetThumbnailState(); - } + private SKPaint CreateFillPaint() + { + var paint = GetPaint(); + paint.IsStroke = false; + paint.IsAntialias = Settings.AntiAliasing; + paint.BlendMode = SKBlendMode.SrcOver; + return paint; } - private static string FormatTimeSpanForDisplay(TimeSpan ts) + private SKPaint CreateTextPaint(SKTypeface typeface, float size) { - if (ts.TotalHours >= 1) - { - return string.Format("{0:D2}:{1:D2}:{2:D2}", (int)ts.TotalHours, ts.Minutes, ts.Seconds); - } - else - { - return string.Format("{0:D2}:{1:D2}", (int)ts.TotalMinutes, ts.Seconds); - } + var paint = GetPaint(); + paint.IsStroke = false; + paint.IsAntialias = Settings.AntiAliasing; + paint.TextSize = size; + paint.Typeface = typeface; + return paint; } - public override void Update(float deltaTime) + private bool ShouldRenderMediaPlayer() { - base.Update(deltaTime); - // Animate thumbnail scale/dim - bool isPaused = false; - lock (mediaLock) { isPaused = !GetEffectivePlayingState(); } - float target = isPaused ? 0f : 1f; - thumbnailAnim = Mathf.Lerp(thumbnailAnim, target, Math.Min(1f, thumbnailAnimSpeed * deltaTime)); - - // Use null checks instead, exceptions kill performance on Update() - bool visible = false; + if (!IsEnabled || (Parent != null && !Parent.IsEnabled)) return false; + var home = Res.HomeMenu; - if (home != null && - home.currentBigMenuMode == HomeMenu.BigMenuMode.Media && - RendererMain.Instance?.MainIsland != null && // Null check Instance - RendererMain.Instance.MainIsland.IsHovering) - { - visible = true; - } + var main = RendererMain.Instance; + return home != null && + main?.MainIsland != null && + home.currentBigMenuMode == HomeMenu.BigMenuMode.Media && + main.MainIsland.IsHovering; + } - if (visible) - { - if (cts == null) StartFetchLoop(); - } - else + private void SetChildInteraction(bool enabled) + { + if (childrenAreActive == enabled) return; + + childrenAreActive = enabled; + drawLocalObjects = enabled; + btnPrev.SilentSetActive(enabled); + btnPlay.SilentSetActive(enabled); + btnNext.SilentSetActive(enabled); + + if (!enabled) { - if (cts != null) StopFetchLoop(disposeCached: false); - return; // Skip the rest if not visible + visualiser.SilentSetActive(false); + SetVisualiserCapture(false); } + } - // Geometry and caching step - // Calculate these ONCE per frame to reuse in Layout, Seek, and Hover logic - var widgetBounds = GetRect().Rect; // Call GetRect only once + private void SetVisualiserCapture(bool enabled) + { + if (visualiserCaptureEnabled == enabled) return; - // Pre-calculate bar geometry used for both seeking and hovering - float barWidth = widgetBounds.Width - 2 * timelineSidePadding; - // Safety check for negative width - if (barWidth < 1f) barWidth = 1f; + visualiserCaptureEnabled = enabled; + visualiser.SetCapturing(enabled, resetBarsOnStop: false); + } - float barX = widgetBounds.Left + (widgetBounds.Width - barWidth) / 2f; + private void SubscribeToThumbnailService() + { + if (isThumbnailSubscribed) return; - // Determine button bottom safely - float btnBottom = (btnPrev != null) - ? (btnPrev.LocalPosition.Y + btnPrev.Size.Y) - : (widgetBounds.Bottom - 20f); + MediaThumbnailService.Instance.ThumbnailChanged += OnThumbnailChanged; + isThumbnailSubscribed = true; + } - float barY = widgetBounds.Top + btnBottom + timelineBarPadding; - if (barY + timelineHeight > widgetBounds.Bottom) - barY = widgetBounds.Bottom - timelineHeight - timelineBarPadding; + private void UnsubscribeFromThumbnailService() + { + if (!isThumbnailSubscribed) return; - var timelineBarRect = SKRect.Create(barX, barY, barWidth, timelineHeight); - var mousePos = RendererMain.CursorPosition; + try { MediaThumbnailService.Instance.ThumbnailChanged -= OnThumbnailChanged; } catch { } + isThumbnailSubscribed = false; + } + private void SubscribeToTimelineEvents() + { + if (isTimelineSubscribed) return; - // Animator step - // (If possible, cache these delegates as fields to avoid per-frame GC allocation) - animator.Update(deltaTime, - () => { lock (mediaLock) { return pendingImage != null; } }, - onStart: () => { lock (mediaLock) { previousImage = thumbnailImage; } }, - onMidFlip: () => - { - lock (mediaLock) - { - if (thumbnailImage != null) - { - try { thumbnailImage.Dispose(); } catch { } - } - thumbnailImage = pendingImage; - thumbnailFingerprint = pendingFingerprint; // Update fingerprint here - pendingImage = null; - pendingFingerprint = null; + MediaInfo.TimelineChanged += OnTimelineChanged; + isTimelineSubscribed = true; + } - currentMediaKey = pendingMediaKey; - pendingMediaKey = null; + private void UnsubscribeFromTimelineEvents() + { + if (!isTimelineSubscribed) return; - if (pendingMedia != null) - { - currentMedia = pendingMedia; - pendingMedia = null; - optimisticActive = false; - timelineFetchedOnce = false; - lastTimelineResync = DateTime.MinValue; - } - } - }, - onFinish: () => - { - if (previousImage != null) - { - try { previousImage.Dispose(); } catch { } - previousImage = null; - } - }); + try { MediaInfo.TimelineChanged -= OnTimelineChanged; } catch { } + isTimelineSubscribed = false; + } + private void StartTimelineLoop() + { + if (timelineCts != null) return; - // Timeline snapshot logic - TimeSpan? sampleElapsed = null; - TimeSpan? sampleDuration = null; + timelineCts = new CancellationTokenSource(); + var token = timelineCts.Token; - lock (mediaLock) + _ = Task.Run(async () => { - if (lastSampleElapsed.HasValue && lastSampleReceivedAt != DateTime.MinValue) - { - // lastSampleDuration may be null for some sessions - sampleDuration = lastSampleDuration; + await FetchTimelineSampleAsync(token).ConfigureAwait(false); - if (userIsSeeking) + while (!token.IsCancellationRequested) + { + try { - sampleElapsed = userSeekElapsed; + await Task.Delay(timelineFetchInterval, token).ConfigureAwait(false); } - else + catch (OperationCanceledException) { - // Only do the DateTime math if we are actually playing - if (lastPlaybackStatus == GlobalSystemMediaTransportControlsSessionPlaybackStatus.Playing) - { - sampleElapsed = lastSampleElapsed.Value + (DateTime.UtcNow - lastSampleReceivedAt); - } - else - { - sampleElapsed = lastSampleElapsed.Value; - } + break; } - // Sync external timeline object if available - if (currentTimeline != null && sampleElapsed.HasValue) - { - try - { - currentTimeline.Position = currentTimeline.StartTime + sampleElapsed.Value; - if (sampleDuration.HasValue) - currentTimeline.EndTime = currentTimeline.StartTime + sampleDuration.Value; - currentTimeline.PlaybackStatus = lastPlaybackStatus; - } - catch { } - } - } - else - { - optimisticActive = false; - currentTimeline = null; + await FetchTimelineSampleAsync(token).ConfigureAwait(false); } - } + }, token); + } - // Timeline update logic - // Some sessions (browsers) do not provide EndTime; still update elapsed so the seconds advance - if (sampleElapsed.HasValue) - { - timelinePosition = sampleElapsed.Value; + private void StopTimelineLoop() + { + var source = timelineCts; + timelineCts = null; - if (sampleDuration.HasValue) - { - timelineDuration = sampleDuration.Value; + if (source == null) return; - if (timelinePosition < TimeSpan.Zero) timelinePosition = TimeSpan.Zero; + try { source.Cancel(); } catch { } + try { source.Dispose(); } catch { } + } - if (timelinePosition > timelineDuration) timelinePosition = timelineDuration; - } - else - { - // No duration available for this session - timelineDuration = null; - } + private void RequestTimelineRefresh() + { + var token = timelineCts?.Token ?? CancellationToken.None; + if (token.IsCancellationRequested) return; - isPlayingFlag = (lastPlaybackStatus == GlobalSystemMediaTransportControlsSessionPlaybackStatus.Playing); - } - else + if (Volatile.Read(ref timelineFetchRunning) != 0) { - timelinePosition = null; - timelineDuration = null; - isPlayingFlag = false; - lastSampleKey = null; + Interlocked.Exchange(ref timelineRefreshPending, 1); + return; } + _ = FetchTimelineSampleAsync(token); + } - // Visual interpolation - float targetFill = 0f; - bool haveTarget = false; - - // Use cached duration total seconds to avoid repeated property access - double totalDurSeconds = (timelineDuration.HasValue) ? timelineDuration.Value.TotalSeconds : 0; - - if (totalDurSeconds > 0) + private async Task FetchTimelineSampleAsync(CancellationToken token) + { + if (token.IsCancellationRequested || userIsSeeking) return; + if (Interlocked.CompareExchange(ref timelineFetchRunning, 1, 0) != 0) { - if (userIsSeeking) - { - targetFill = Math.Clamp((float)(userSeekElapsed.TotalSeconds / totalDurSeconds), 0f, 1f); - haveTarget = true; - } - else if (timelinePosition.HasValue) - { - targetFill = Math.Clamp((float)(timelinePosition.Value.TotalSeconds / totalDurSeconds), 0f, 1f); - haveTarget = true; - } + Interlocked.Exchange(ref timelineRefreshPending, 1); + return; } - if (haveTarget) + try { - // 30f * deltaTime is simple, but ensure deltaTime isn't huge (spike protection) - float t = Math.Min(1f, 30f * deltaTime); - displayFill = Mathf.Lerp(displayFill, targetFill, t); - } - + var timeline = await MediaInfo.FetchCurrentTimelineAsync(forceRefresh: true).ConfigureAwait(false); + if (token.IsCancellationRequested || userIsSeeking) return; - // Updating layout - // Use widgetBounds - float padding = 0f; - float thumbSize = Math.Min(widgetBounds.Height - padding * 2f, widgetBounds.Height * 1f); - SKRect thumbRect = SKRect.Create(widgetBounds.Left + padding, widgetBounds.Top + padding, thumbSize, thumbSize); - - // Pre-calculate common layout values - float btnSize = 28f; - float btnSpacing = 8f; - float buttonsYOffset = 16f + 14f + 12f + 24f; // titleY + titleH + artistH + gap - float startXLocal = thumbRect.Right - widgetBounds.Left - 45f; - - // Set positions (null checks instead of try-catch) - if (btnPrev != null) btnPrev.LocalPosition = new Vec2(startXLocal, buttonsYOffset); - if (btnPlay != null) + ApplyTimelineSample(timeline, DateTime.UtcNow); + } + catch { - btnPlay.LocalPosition = new Vec2(startXLocal + (btnSize + btnSpacing), buttonsYOffset); - - // Icon update - bool effectivePlaying = GetEffectivePlayingState(); - var icon = effectivePlaying ? (Resources.Res.Pause ?? Resources.Res.Stop) : Resources.Res.Play; - - // Only update the image if it actually changed (avoids invalidation overhead) - if (btnPlay.Image.Image != icon) + } + finally + { + Interlocked.Exchange(ref timelineFetchRunning, 0); + if (Interlocked.Exchange(ref timelineRefreshPending, 0) != 0 && + !token.IsCancellationRequested && + !userIsSeeking) { - btnPlay.Image.Image = icon; - btnPlay.Image.Color = Theme.IconColor; + _ = FetchTimelineSampleAsync(token); } } - if (btnNext != null) btnNext.LocalPosition = new Vec2(startXLocal + 2 * (btnSize + btnSpacing), buttonsYOffset); + } + private void OnTimelineChanged(MediaTimeline? timeline) + { + if (userIsSeeking) return; - // Input handling for seeking and hover states - // Logic consolidated to use 'timelineBarRect' - bool isMouseInBar = timelineBarRect.Contains(mousePos.X, mousePos.Y); - - // Hover state - isHoveringOverTimeline = isMouseInBar && IsHovering && !timelineBar.IsLocked; + ApplyTimelineSample(timeline, DateTime.UtcNow); + MainForm.Instance?.RequestRenderBurst(250); + } - // Seeking state - // Mouse down (start seek) - if (IsHovering && IsMouseDown && !mouseDownOverTimeline && isMouseInBar && !timelineBar.IsLocked) + private void ApplyTimelineSample(MediaTimeline? timeline, DateTime receivedAt) + { + lock (mediaLock) { - mouseDownOverTimeline = true; - userIsSeeking = true; + if (timeline == null) + { + ResetTimelineLocked(); + return; + } - // Calculate initial seek - if (totalDurSeconds > 0) + var duration = timeline.EndTime - timeline.StartTime; + if (duration < TimeSpan.Zero) duration = TimeSpan.Zero; + + var elapsed = timeline.Position - timeline.StartTime; + if (elapsed < TimeSpan.Zero) elapsed = TimeSpan.Zero; + if (duration > TimeSpan.Zero && elapsed > duration) elapsed = duration; + var sampleAt = GetTimelineSampleTime(timeline, receivedAt); + + if (pendingSeekElapsed.HasValue) + { + var pendingElapsed = GetPendingSeekProjectionLocked(sampleAt); + var previousProjection = lastSampleElapsed.HasValue + ? GetTimelineProjectionLocked(sampleAt) + : pendingElapsed; + bool pendingExpired = receivedAt > pendingSeekUntil; + bool matchesPendingSeek = Math.Abs((elapsed - pendingElapsed).TotalSeconds) <= TimelineSeekMatchToleranceSeconds; + bool closerToPendingSeek = + Math.Abs((elapsed - pendingElapsed).TotalSeconds) <= + Math.Abs((elapsed - previousProjection).TotalSeconds) + 0.05; + + if (!pendingExpired && (!matchesPendingSeek || !closerToPendingSeek)) + return; + + ClearPendingSeekLocked(); + } + + if (lastSampleElapsed.HasValue && + lastSampleReceivedAt != DateTime.MinValue && + DurationsClose(lastSampleDuration, duration)) { - userSeekElapsed = timelinePosition ?? TimeSpan.Zero; - float rel = Math.Clamp((mousePos.X - barX) / barWidth, 0f, 1f); - userSeekElapsed = TimeSpan.FromSeconds(rel * totalDurSeconds); + var projectedElapsed = GetTimelineProjectionLocked(sampleAt); + double correctionSeconds = (elapsed - projectedElapsed).TotalSeconds; + bool wasPlaying = lastPlaybackStatus == GlobalSystemMediaTransportControlsSessionPlaybackStatus.Playing; + bool isPlaying = timeline.PlaybackStatus == GlobalSystemMediaTransportControlsSessionPlaybackStatus.Playing; + + if (wasPlaying && isPlaying && Math.Abs(correctionSeconds) <= TimelineSampleJitterToleranceSeconds) + { + lastSampleDuration = duration; + lastPlaybackStatus = timeline.PlaybackStatus; + currentTimeline = timeline; + optimisticActive = false; + return; + } + + if (wasPlaying && + !isPlaying && + correctionSeconds < 0 && + Math.Abs(correctionSeconds) <= TimelineSampleJitterToleranceSeconds) + { + elapsed = projectedElapsed; + } } + + lastSampleElapsed = elapsed; + lastSampleDuration = duration; + lastSampleReceivedAt = sampleAt; + lastPlaybackStatus = timeline.PlaybackStatus; + currentTimeline = timeline; + optimisticActive = false; + } + } + + private static DateTime GetTimelineSampleTime(MediaTimeline timeline, DateTime fallback) + { + var timestamp = timeline.LastUpdatedTime != default + ? timeline.LastUpdatedTime + : timeline.CachedAt; + + if (timestamp == default) + return fallback; + + var utc = timestamp.UtcDateTime; + if (utc > fallback.AddSeconds(2) || utc < fallback.AddHours(-6)) + return fallback; + + return utc; + } + + private TimeSpan GetTimelineProjectionLocked(DateTime now) + { + if (!lastSampleElapsed.HasValue) + return TimeSpan.Zero; + + var elapsed = lastSampleElapsed.Value; + if (lastPlaybackStatus == GlobalSystemMediaTransportControlsSessionPlaybackStatus.Playing && + lastSampleReceivedAt != DateTime.MinValue) + { + elapsed += now - lastSampleReceivedAt; + } + + return ClampTimelineElapsed(elapsed, lastSampleDuration); + } + + private TimeSpan GetPendingSeekProjectionLocked(DateTime now) + { + if (!pendingSeekElapsed.HasValue) + return TimeSpan.Zero; + + var elapsed = pendingSeekElapsed.Value; + if (lastPlaybackStatus == GlobalSystemMediaTransportControlsSessionPlaybackStatus.Playing && + pendingSeekStartedAt != DateTime.MinValue) + { + elapsed += now - pendingSeekStartedAt; } - // Dragging - if (mouseDownOverTimeline && IsMouseDown) + return ClampTimelineElapsed(elapsed, lastSampleDuration); + } + + private void BeginPendingSeekLocked(TimeSpan elapsed, DateTime now) + { + pendingSeekElapsed = ClampTimelineElapsed(elapsed, lastSampleDuration); + pendingSeekStartedAt = now; + pendingSeekUntil = now + timelineSeekConfirmationWindow; + } + + private void ClearPendingSeekLocked() + { + pendingSeekElapsed = null; + pendingSeekStartedAt = DateTime.MinValue; + pendingSeekUntil = DateTime.MinValue; + } + + private static TimeSpan ClampTimelineElapsed(TimeSpan elapsed, TimeSpan? duration) + { + if (elapsed < TimeSpan.Zero) + return TimeSpan.Zero; + + if (duration.HasValue && duration.Value > TimeSpan.Zero && elapsed > duration.Value) + return duration.Value; + + return elapsed; + } + + private static bool DurationsClose(TimeSpan? previous, TimeSpan next) + { + if (!previous.HasValue) + return false; + + return Math.Abs((previous.Value - next).TotalSeconds) <= 1.0; + } + + private void HandlePlayPauseClick() + { + bool willPlay = !GetEffectivePlayingState(); + optimisticState = willPlay; + optimisticActive = true; + optimisticStartedAt = DateTime.UtcNow; + isPlayingFlag = willPlay; + UpdatePlayIcon(willPlay); + + lock (mediaLock) { - if (totalDurSeconds > 0) + var now = optimisticStartedAt; + if (!lastSampleElapsed.HasValue) + lastSampleElapsed = TimeSpan.Zero; + + if (!willPlay && + lastPlaybackStatus == GlobalSystemMediaTransportControlsSessionPlaybackStatus.Playing && + lastSampleReceivedAt != DateTime.MinValue) { - float rel = Math.Clamp((mousePos.X - barX) / barWidth, 0f, 1f); - userSeekElapsed = TimeSpan.FromSeconds(rel * totalDurSeconds); + lastSampleElapsed += now - lastSampleReceivedAt; } + + lastSampleReceivedAt = now; + lastPlaybackStatus = willPlay + ? GlobalSystemMediaTransportControlsSessionPlaybackStatus.Playing + : GlobalSystemMediaTransportControlsSessionPlaybackStatus.Paused; } - // Mouse Up (Commit) - if (mouseDownOverTimeline && !IsMouseDown) + _ = Task.Run(async () => { - mouseDownOverTimeline = false; - if (userIsSeeking) + try { - TimeSpan? start = currentTimeline?.StartTime; - if (start.HasValue) - { - var seekTarget = start.Value + userSeekElapsed; - // Fire and forget task - _ = Task.Run(async () => - { - try - { - var ok = await MediaInfo.SeekCurrentSessionAsync(seekTarget).ConfigureAwait(false); - if (ok) - { - lock (mediaLock) - { - lastSampleElapsed = userSeekElapsed; - lastSampleReceivedAt = DateTime.UtcNow; - lastPlaybackStatus = GlobalSystemMediaTransportControlsSessionPlaybackStatus.Playing; - timelineFetchedOnce = false; - } - } - } - catch { } // Task exception is isolated here - }); - } - userIsSeeking = false; + if (!await MediaInfo.TryTogglePlayPauseAsync().ConfigureAwait(false)) + controller.PlayPause(); + } + catch + { + controller.PlayPause(); } + finally + { + RequestTimelineRefresh(); + } + }); + } + + private bool GetEffectivePlayingState() + { + return optimisticActive ? optimisticState : isPlayingFlag; + } + + private void OnThumbnailChanged(object? sender, MediaChangedEventArgs e) + { + int version = Interlocked.Increment(ref thumbnailDecodeVersion); + var media = e.Media; + var bytes = e.ThumbnailBytes; + ApplyServicePlaybackStatus(MediaThumbnailService.Instance.LastPlaybackStatus); + + if (media != null) + { + bool requestTimeline; + lock (mediaLock) + { + requestTimeline = SetCurrentMediaLocked(media); + mediaClearRequestedAt = DateTime.MinValue; + } + + if (requestTimeline) + RequestTimelineRefresh(); + } + + if (bytes != null && bytes.Length > 0) + { + QueueThumbnailDecode((byte[])bytes.Clone(), media, version); + return; + } + + if (media != null) + { + RequestMissingThumbnail(media, version); + return; } - string newTitle = !string.IsNullOrEmpty(currentMedia?.Title) ? currentMedia.Title : "No media playing"; + lock (mediaLock) + { + mediaClearRequestedAt = DateTime.UtcNow; + } + } - if (fullTitleText != newTitle) + private bool HasMedia() + { + lock (mediaLock) { - fullTitleText = newTitle; - // Only measure when string changes - var paint = GetPaint(); - paint.TextSize = 14f; - paint.Typeface = Res.SFProBold; // Accessing Res property might be slight overhead, ensure cached if possible - titleTextWidth = paint.MeasureText(fullTitleText); + return currentMedia != null || thumbnailImage != null || pendingImage != null; + } + } - // Reset scroll on change - isTitleScrolling = false; - titleScrollOffset = 0f; - titleScrollTimer = 0f; + private void ApplyServicePlaybackStatus(GlobalSystemMediaTransportControlsSessionPlaybackStatus? status) + { + if (!status.HasValue) return; + + var value = status.Value; + bool playing = value == GlobalSystemMediaTransportControlsSessionPlaybackStatus.Playing; + var now = DateTime.UtcNow; + + if (optimisticActive && + playing != optimisticState && + (now - optimisticStartedAt) < optimisticStatusGrace) + { + return; } - if (fullTitleText.Length > titleScrollCharThreshold) + lock (mediaLock) { - isTitleScrolling = true; - if (titleScrollTimer < titleScrollDelay) + if (!lastSampleElapsed.HasValue) + lastSampleElapsed = timelinePosition ?? TimeSpan.Zero; + + if (lastPlaybackStatus == GlobalSystemMediaTransportControlsSessionPlaybackStatus.Playing && + value != GlobalSystemMediaTransportControlsSessionPlaybackStatus.Playing && + lastSampleReceivedAt != DateTime.MinValue) { - titleScrollTimer += deltaTime; + lastSampleElapsed += now - lastSampleReceivedAt; + if (lastSampleDuration.HasValue && lastSampleElapsed > lastSampleDuration) + lastSampleElapsed = lastSampleDuration; } - else + + lastPlaybackStatus = value; + lastSampleReceivedAt = now; + optimisticActive = false; + } + + isPlayingFlag = playing; + } + + private void RequestMissingThumbnail(DynamicWin.Utils.Media media, int version) + { + var now = DateTime.UtcNow; + if ((now - lastMissingThumbnailFetch) < missingThumbnailFetchInterval) return; + + lastMissingThumbnailFetch = now; + _ = Task.Run(async () => + { + try { - titleScrollOffset += titleScrollSpeed * deltaTime; - if (titleScrollOffset > titleTextWidth + 20f) - { - titleScrollOffset = 0f; - titleScrollTimer = 0f; - } + var bytes = await MediaInfo.FetchCurrentThumbnailBytesAsync(forceRefresh: false).ConfigureAwait(false); + if (version != Volatile.Read(ref thumbnailDecodeVersion)) return; + if (bytes != null && bytes.Length > 0) + QueueThumbnailDecode((byte[])bytes.Clone(), media, version); } - } + catch + { + } + }); + } + private void QueueThumbnailDecode(byte[] bytes, DynamicWin.Utils.Media? media, int version) + { + string mediaKey = BuildMediaKey(media); - // Elapsed time display - // Update displayed elapsed seconds when we have an elapsed sample even if duration is unknown - if (sampleElapsed.HasValue) + _ = Task.Run(() => { - float desired = (float)(userIsSeeking ? userSeekElapsed.TotalSeconds : sampleElapsed.Value.TotalSeconds); + if (version != Volatile.Read(ref thumbnailDecodeVersion)) return; - if (userIsSeeking || !displayedElapsedInitialized) + SKImage? decoded = null; + ulong? fingerprint = null; + + try { - displayedElapsedSeconds = desired; - displayedElapsedInitialized = true; + decoded = MediaThumbnailUtils.DecodeBytesToImageAndFingerprint(bytes, out fingerprint); } - else + catch { - if (!isPlayingFlag) + decoded = null; + } + + if (decoded == null) return; + + bool requestTimeline = false; + + lock (mediaLock) + { + if (version != Volatile.Read(ref thumbnailDecodeVersion)) + { + decoded.Dispose(); + return; + } + + if (fingerprint.HasValue && + thumbnailFingerprint.HasValue && + fingerprint.Value == thumbnailFingerprint.Value) { - displayedElapsedSeconds = desired; + decoded.Dispose(); + requestTimeline = SetCurrentMediaLocked(media); + } + else if (thumbnailImage == null && animator.State == MediaAnimator.AnimState.Idle) + { + thumbnailImage = decoded; + thumbnailFingerprint = fingerprint; + requestTimeline = SetCurrentMediaLocked(media); } else { - // Smoothly advance displayed elapsed while playing - displayedElapsedSeconds = Mathf.Lerp(displayedElapsedSeconds, desired, Math.Min(1f, 12f * deltaTime)); + DisposeImage(ref pendingImage); + pendingImage = decoded; + pendingFingerprint = fingerprint; + pendingMedia = CopyMedia(media); + pendingMediaKey = mediaKey; } } + + if (requestTimeline) + RequestTimelineRefresh(); + }); + } + + private static DynamicWin.Utils.Media? CopyMedia(DynamicWin.Utils.Media? media) + { + if (media == null) return null; + + return new DynamicWin.Utils.Media + { + Title = media.Title, + Artist = media.Artist, + ThumbnailData = null + }; + } + + private static string BuildMediaKey(DynamicWin.Utils.Media? media) + { + if (media == null) return string.Empty; + return $"{media.Title ?? string.Empty}|{media.Artist ?? string.Empty}"; + } + + private bool SetCurrentMediaLocked(DynamicWin.Utils.Media? media) + { + if (media == null) return false; + + string key = BuildMediaKey(media); + bool changed = key != currentMediaKey; + currentMedia = CopyMedia(media); + currentMediaKey = key; + optimisticActive = false; + + if (changed) + ResetTimelineLocked(); + + return changed; + } + + private void ResetTimelineLocked() + { + lastSampleElapsed = null; + lastSampleDuration = null; + lastSampleReceivedAt = DateTime.MinValue; + lastPlaybackStatus = GlobalSystemMediaTransportControlsSessionPlaybackStatus.Closed; + currentTimeline = null; + ClearPendingSeekLocked(); + } + + private void ClearMediaAfterDebounce() + { + bool shouldClear; + lock (mediaLock) + { + shouldClear = mediaClearRequestedAt != DateTime.MinValue && + (DateTime.UtcNow - mediaClearRequestedAt) >= mediaClearDelay; } - // Re-use calculation - float targetExtra = userIsSeeking ? 3f : (isHoveringOverTimeline ? 6f : 0f); - timelineExtraHeight = Mathf.Lerp(timelineExtraHeight, targetExtra, Math.Min(1f, 12f * deltaTime)); + if (shouldClear) + ResetMediaState(clearText: false); + } - // Ensure metadata is populated when we have an image but no metadata - try + private void ResetMediaState(bool clearText) + { + Interlocked.Increment(ref thumbnailDecodeVersion); + + lock (mediaLock) { - bool needMeta = false; - lock (mediaLock) - { - needMeta = (currentMedia == null) && (thumbnailImage != null || pendingImage != null); - } + animator.ForceFinish(); + DisposeAllImagesLocked(); + currentMedia = null; + pendingMedia = null; + currentMediaKey = string.Empty; + pendingMediaKey = string.Empty; + pendingFingerprint = null; + thumbnailFingerprint = null; + optimisticActive = false; + userIsSeeking = false; + mouseDownOverTimeline = false; + mediaClearRequestedAt = DateTime.MinValue; + ResetTimelineLocked(); + } + + displayFill = 0f; + displayedElapsedSeconds = 0f; + displayedElapsedInitialized = false; + cachedTimelineElapsedSecond = int.MinValue; + cachedTimelineDurationSecond = int.MinValue; + thumbnailAnim = 1f; + titleScrollOffset = 0f; + titleScrollTimer = 0f; + isTitleScrolling = false; + + if (clearText) + { + SetTextBlobs("No media playing", "No media playing"); + SetTimelineText("--:--", "--:--"); + } + } + + private void DisposeAllImagesLocked() + { + var thumb = thumbnailImage; + var pending = pendingImage; + var previous = previousImage; + + thumbnailImage = null; + pendingImage = null; + previousImage = null; + + try { thumb?.Dispose(); } catch { } + if (pending != null && !ReferenceEquals(pending, thumb)) + { + try { pending.Dispose(); } catch { } + } + if (previous != null && !ReferenceEquals(previous, thumb) && !ReferenceEquals(previous, pending)) + { + try { previous.Dispose(); } catch { } + } + } + + private static void DisposeImage(ref SKImage? image) + { + var old = image; + image = null; + try { old?.Dispose(); } catch { } + } + + private void StepThumbnailAnimation(float deltaTime) + { + float target = GetEffectivePlayingState() ? 1f : 0f; + thumbnailAnim = Mathf.Lerp(thumbnailAnim, target, Math.Min(1f, ThumbnailAnimSpeed * deltaTime)); + } - if (needMeta && (DateTime.UtcNow - lastMetadataFetch) >= metadataFetchInterval) + private void StepThumbnailSwapAnimation(float deltaTime) + { + animator.Update(deltaTime, + () => { - // Throttle and ensure only one fetch runs at a time - if (System.Threading.Interlocked.CompareExchange(ref metadataFetchRunning, 1, 0) == 0) + lock (mediaLock) { return pendingImage != null; } + }, + onStart: () => + { + lock (mediaLock) { - lastMetadataFetch = DateTime.UtcNow; - _ = Task.Run(async () => - { - try - { - var meta = await MediaInfo.FetchCurrentMediaAsync(forceRefresh: false).ConfigureAwait(false); - if (meta != null) - { - lock (mediaLock) - { - // Only adopt if we still lack metadata or keys differ - if (currentMedia == null) - { - currentMedia = meta; - try { currentMediaKey = $"{meta.Title ?? ""}|{meta.Artist ?? ""}|0"; } catch { currentMediaKey = null; } - optimisticActive = false; - } - } - } - } - catch { } - finally - { - System.Threading.Interlocked.Exchange(ref metadataFetchRunning, 0); - } - }); + previousImage = thumbnailImage; } - } + }, + onMidFlip: OnAnimatorMidFlip, + onFinish: OnAnimatorFinish); + } + + private void OnAnimatorMidFlip() + { + bool requestTimeline = false; + + lock (mediaLock) + { + if (pendingImage == null) return; + + thumbnailImage = pendingImage; + thumbnailFingerprint = pendingFingerprint; + pendingImage = null; + pendingFingerprint = null; + + if (pendingMedia != null) + requestTimeline = SetCurrentMediaLocked(pendingMedia); + + pendingMedia = null; + if (!string.IsNullOrEmpty(pendingMediaKey)) + currentMediaKey = pendingMediaKey; + pendingMediaKey = string.Empty; } - catch { } + + if (requestTimeline) + RequestTimelineRefresh(); } - private void OnThumbnailChanged(object? sender, MediaChangedEventArgs e) + private void OnAnimatorFinish() { - try + if (previousImage != null && !ReferenceEquals(previousImage, thumbnailImage)) + previousImage.Dispose(); + + previousImage = null; + } + + private void UpdateTimelineSnapshot() + { + TimeSpan? sampleElapsed = null; + TimeSpan? sampleDuration = null; + GlobalSystemMediaTransportControlsSessionPlaybackStatus playbackStatus; + var now = DateTime.UtcNow; + + lock (mediaLock) { - var bytes = e.ThumbnailBytes; - var media = e.Media; + playbackStatus = lastPlaybackStatus; - // Adopt metadata immediately - lock (mediaLock) + if (pendingSeekElapsed.HasValue && now > pendingSeekUntil) + ClearPendingSeekLocked(); + + if (userIsSeeking) { - if (media != null) - { - currentMedia = media; - currentMediaKey = $"{media.Title ?? ""}|{media.Artist ?? ""}|{(bytes?.Length ?? 0)}"; - optimisticActive = false; - timelineFetchedOnce = false; - lastTimelineResync = DateTime.MinValue; - } + sampleDuration = lastSampleDuration; + sampleElapsed = userSeekElapsed; + } + else if (pendingSeekElapsed.HasValue) + { + sampleDuration = lastSampleDuration; + sampleElapsed = GetPendingSeekProjectionLocked(now); + } + else if (lastSampleElapsed.HasValue && lastSampleReceivedAt != DateTime.MinValue) + { + sampleDuration = lastSampleDuration; + sampleElapsed = GetTimelineProjectionLocked(now); + } + } - // Clear any one-shot pending bytes; we'll handle decoding directly below - pendingThumbnailBytesFromService = null; - pendingMedia = media; - mediaNeedsUpdate = false; - mediaClearRequestedAt = DateTime.MinValue; + if (sampleElapsed.HasValue && + !userIsSeeking && + playbackStatus == GlobalSystemMediaTransportControlsSessionPlaybackStatus.Playing && + timelinePosition.HasValue && + sampleElapsed.Value < timelinePosition.Value && + (timelinePosition.Value - sampleElapsed.Value).TotalSeconds <= TimelineSampleJitterToleranceSeconds) + { + sampleElapsed = timelinePosition.Value; + if (sampleDuration.HasValue) + { + sampleElapsed = ClampTimelineElapsed(sampleElapsed.Value, sampleDuration.Value); } + } + + if (sampleElapsed.HasValue) + { + timelinePosition = sampleElapsed.Value; + timelineDuration = sampleDuration; - // Helper to queue a background decode and set pendingImage when appropriate - void DecodeAndQueueBytes(byte[] bts, Media? md) + if (timelineDuration.HasValue && timelineDuration.Value > TimeSpan.Zero) { - _ = Task.Run(() => - { - SKImage? img = null; - ulong? fp = null; - try - { - img = MediaThumbnailUtils.DecodeBytesToImageAndFingerprint(bts, out fp); - } - catch { img = null; fp = null; } + if (timelinePosition < TimeSpan.Zero) timelinePosition = TimeSpan.Zero; + if (timelinePosition > timelineDuration) timelinePosition = timelineDuration; + } - if (img == null) return; + isPlayingFlag = playbackStatus == GlobalSystemMediaTransportControlsSessionPlaybackStatus.Playing; + } + else + { + timelinePosition = null; + timelineDuration = null; + isPlayingFlag = false; + displayedElapsedInitialized = false; + } + } - lock (mediaLock) - { - // If visually identical to current, adopt metadata only - if (fp.HasValue && thumbnailFingerprint.HasValue && fp.Value == thumbnailFingerprint.Value) - { - if (md != null) - { - currentMedia = md; - currentMediaKey = $"{md.Title ?? ""}|{md.Artist ?? ""}|{bts.Length}"; - } - optimisticActive = false; - try { img.Dispose(); } catch { } - return; - } + private void UpdateDisplayFill(float deltaTime) + { + double durationSeconds = timelineDuration?.TotalSeconds ?? 0; + + if (durationSeconds <= 0 || !timelinePosition.HasValue) + { + displayFill = Mathf.Lerp(displayFill, 0f, Math.Min(1f, 18f * deltaTime)); + displayedElapsedInitialized = false; + + float emptyTargetExtra = userIsSeeking ? 3f : (isHoveringOverTimeline ? 6f : 0f); + timelineExtraHeight = Mathf.Lerp(timelineExtraHeight, emptyTargetExtra, Math.Min(1f, 12f * deltaTime)); + return; + } + + float targetSeconds = (float)Math.Clamp( + (userIsSeeking ? userSeekElapsed : timelinePosition.Value).TotalSeconds, + 0, + durationSeconds); + + if (userIsSeeking || !displayedElapsedInitialized || !isPlayingFlag) + { + displayedElapsedSeconds = targetSeconds; + displayedElapsedInitialized = true; + } + else + { + float deltaSeconds = targetSeconds - displayedElapsedSeconds; + + if (Math.Abs(deltaSeconds) >= TimelineVisualSnapSeconds) + { + displayedElapsedSeconds = targetSeconds; + } + else if (deltaSeconds >= 0f) + { + displayedElapsedSeconds = Mathf.Lerp(displayedElapsedSeconds, targetSeconds, Math.Min(1f, 12f * deltaTime)); + } + } + + displayedElapsedSeconds = Math.Clamp(displayedElapsedSeconds, 0f, (float)durationSeconds); + displayFill = Math.Clamp(displayedElapsedSeconds / Math.Max(1f, (float)durationSeconds), 0f, 1f); + + float targetExtra = userIsSeeking ? 3f : (isHoveringOverTimeline ? 6f : 0f); + timelineExtraHeight = Mathf.Lerp(timelineExtraHeight, targetExtra, Math.Min(1f, 12f * deltaTime)); + } + + private void UpdateButtonLayoutAndIcon() + { + const float btnSize = 28f; + const float btnSpacing = 8f; + const float buttonOffsetFromThumbnail = 25f; + float buttonsYOffset = 16f + TitleTextSize + ArtistTextSize + 24f; + float startXLocal = thumbnailRect.Right - layoutRect.Left + buttonOffsetFromThumbnail; + + SetLocalPosition(btnPrev, new Vec2(startXLocal, buttonsYOffset)); + SetLocalPosition(btnPlay, new Vec2(startXLocal + btnSize + btnSpacing, buttonsYOffset)); + SetLocalPosition(btnNext, new Vec2(startXLocal + 2f * (btnSize + btnSpacing), buttonsYOffset)); + + UpdatePlayIcon(GetEffectivePlayingState()); + } + + private void UpdatePlayIcon(bool isPlaying) + { + var icon = isPlaying ? (Res.Pause ?? Res.Stop) : Res.Play; + if (icon == null || ReferenceEquals(btnPlay.Image.Image, icon)) return; + + btnPlay.Image.Image = icon; + btnPlay.Image.Color = Theme.IconColor; + } + + private static void SetLocalPosition(UIObject obj, Vec2 position) + { + if (Math.Abs(obj.LocalPosition.X - position.X) <= 0.001f && + Math.Abs(obj.LocalPosition.Y - position.Y) <= 0.001f) + { + return; + } + + obj.LocalPosition = position; + } + + private void HandleTimelineInput() + { + var mousePos = RendererMain.CursorPosition; + bool isMouseInBar = timelineBarRect.Contains(mousePos.X, mousePos.Y); + isHoveringOverTimeline = isMouseInBar && IsHovering; + double durationSeconds = timelineDuration?.TotalSeconds ?? 0; + + if (IsHovering && IsMouseDown && !mouseDownOverTimeline && isMouseInBar && durationSeconds > 0) + { + mouseDownOverTimeline = true; + userIsSeeking = true; + userSeekElapsed = GetSeekTime(mousePos.X, durationSeconds); + } - // Replace any existing pending image - if (pendingImage != null) - { - try { pendingImage.Dispose(); } catch { } - pendingImage = null; - pendingFingerprint = null; - pendingMediaKey = null; - pendingMedia = null; - } + if (mouseDownOverTimeline && IsMouseDown && durationSeconds > 0) + userSeekElapsed = GetSeekTime(mousePos.X, durationSeconds); - pendingImage = img; - pendingFingerprint = fp; - pendingMedia = md; - pendingMediaKey = (md == null) ? string.Empty : $"{md.Title ?? ""}|{md.Artist ?? ""}|{bts.Length}"; - } - }); - } + if (mouseDownOverTimeline && !IsMouseDown) + { + mouseDownOverTimeline = false; - // If event provided bytes, decode them immediately - if (bytes != null && bytes.Length > 0) + if (userIsSeeking) { - try + TimeSpan? start; + lock (mediaLock) { - var cloned = (byte[])bytes.Clone(); - DecodeAndQueueBytes(cloned, media); + start = currentTimeline?.StartTime; } - catch { } - - return; - } - // No bytes in event: prefer service cached bytes (fast path) - if (media != null) - { - try + if (start.HasValue) { - var svcBytes = MediaThumbnailService.Instance.GetCurrentThumbnailBytes(); - if (svcBytes != null && svcBytes.Length > 0) + var seekElapsed = userSeekElapsed; + var seekTarget = start.Value + seekElapsed; + lock (mediaLock) { - var cloned = (byte[])svcBytes.Clone(); - DecodeAndQueueBytes(cloned, media); - return; + BeginPendingSeekLocked(seekElapsed, DateTime.UtcNow); } - } - catch { } + MainForm.Instance?.RequestRenderBurst(800); - // If no cached bytes, trigger a one-shot fetch but do not block UI thread - var now = DateTime.UtcNow; - if ((now - lastMediaCheck) > TimeSpan.FromMilliseconds(500)) - { - lastMediaCheck = now; _ = Task.Run(async () => { + bool seekSucceeded = false; try { - var b = await MediaInfo.FetchCurrentThumbnailBytesAsync(forceRefresh: true).ConfigureAwait(false); - if (b != null && b.Length > 0) + seekSucceeded = await MediaInfo.SeekCurrentSessionAsync(seekTarget).ConfigureAwait(false); + if (seekSucceeded) + RequestTimelineRefreshPulse(); + } + catch + { + } + finally + { + if (!seekSucceeded) { lock (mediaLock) { - // stash as consumed so other loops won't duplicate work - pendingThumbnailBytesFromService = null; - pendingMedia = media; - mediaNeedsUpdate = false; + ClearPendingSeekLocked(); } - - DecodeAndQueueBytes((byte[])b.Clone(), media); } + + RequestTimelineRefresh(); } - catch { } }); } - } - else - { - // No media and no bytes: clear thumbnail after a short debounce to avoid flicker - lock (mediaLock) - { - mediaClearRequestedAt = DateTime.UtcNow; - } + + userIsSeeking = false; } } - catch { } } - private void StartFetchLoop() + private TimeSpan GetSeekTime(float mouseX, double durationSeconds) + { + float relative = Math.Clamp((mouseX - timelineBaseRect.Left) / Math.Max(1f, timelineBaseRect.Width), 0f, 1f); + return TimeSpan.FromSeconds(relative * durationSeconds); + } + + private void RequestTimelineRefreshPulse(int count = 6, int intervalMs = 75) { - if (cts != null) return; - cts = new CancellationTokenSource(); - var token = cts.Token; + var token = timelineCts?.Token ?? CancellationToken.None; + if (token.IsCancellationRequested) return; _ = Task.Run(async () => { - while (!token.IsCancellationRequested) + for (int i = 0; i < count && !token.IsCancellationRequested; i++) { + RequestTimelineRefresh(); + try { - // Timeline fetch: perform an initial fetch when we do not yet have a timeline sample - // and also periodically re-sync the timeline so external changes (pause/seek) are detected - try - { - // Timeline re-sync: perform an initial fetch if missing, then refresh at configured interval - if (!userIsSeeking && (!timelineFetchedOnce || (DateTime.UtcNow - lastTimelineResync) >= timelineFetchInterval)) - { - // Force refresh to bypass small MediaInfo timeline cache so external changes are detected - var tl = await MediaInfo.FetchCurrentTimelineAsync(forceRefresh: true).ConfigureAwait(false); - if (tl != null) - { - lock (mediaLock) - { - var absElapsed = tl.Position - tl.StartTime; - var now = DateTime.UtcNow; - - lastSampleElapsed = absElapsed; - lastSampleReceivedAt = now; - lastSampleDuration = tl.EndTime - tl.StartTime; - lastPlaybackStatus = tl.PlaybackStatus; - currentTimeline = tl; - - timelineFetchedOnce = true; - lastTimelineResync = DateTime.UtcNow; - - optimisticActive = false; - } - } - else - { - lock (mediaLock) - { - lastSampleElapsed = null; - lastSampleDuration = null; - currentTimeline = null; - timelineFetchedOnce = false; - - optimisticActive = false; - } - } - } - } - catch (Exception) { } - - byte[]? svcBytes = null; - DynamicWin.Utils.Media? svcMedia = null; - - // Only attempt media/thumbnail decoding occasionally or when service signalled a change - bool shouldProcessMedia = false; - try - { - if (mediaNeedsUpdate || (DateTime.UtcNow - lastMediaCheck) >= mediaCheckInterval) - shouldProcessMedia = true; - } - catch { shouldProcessMedia = false; } - - if (shouldProcessMedia) - { - lastMediaCheck = DateTime.UtcNow; - - // Prefer bytes captured from service event (cheap) over querying MediaInfo repeatedly - lock (mediaLock) - { - if (pendingThumbnailBytesFromService != null && pendingThumbnailBytesFromService.Length > 0) - { - svcBytes = (byte[])pendingThumbnailBytesFromService.Clone(); - svcMedia = pendingMedia; // adopt whatever metadata was provided - mediaNeedsUpdate = false; - } - else if (mediaNeedsUpdate && pendingMedia != null) - { - // Service signalled a change but did not provide bytes (metadata-only update) - // Use the pending metadata directly to avoid relying on MediaInfo cache - svcBytes = null; - svcMedia = pendingMedia; - mediaNeedsUpdate = false; - } - } - - // If no bytes came from service events, only then query MediaInfo (infrequent) - if (svcBytes == null) - { - try - { - var media = await MediaInfo.FetchCurrentMediaAsync().ConfigureAwait(false); - svcMedia = media; - svcBytes = media?.ThumbnailData != null && media.ThumbnailData.Length > 0 ? (byte[])media.ThumbnailData.Clone() : null; - } - catch { svcBytes = null; svcMedia = null; } - } - - // Build lightweight key to detect duplicates - string key = (svcMedia == null) ? string.Empty : $"{svcMedia.Title ?? ""}|{svcMedia.Artist ?? ""}|{(svcBytes?.Length ?? 0)}"; - - // If key matches current or pending, skip decode - if (key == currentMediaKey || key == pendingMediaKey) - { - // nothing to do - if (svcBytes != null) { /*keep bytes for later*/ } - } - else - { - // Decode bytes into SKImage (rate-limited) only when we have new bytes - if (svcBytes != null && svcBytes.Length > 0) - { - SKImage? img = null; - ulong? fp = null; - try - { - img = MediaThumbnailUtils.DecodeBytesToImageAndFingerprint(svcBytes, out fp); - } - catch { img = null; fp = null; } - - bool skipPending = false; - lock (mediaLock) - { - if (img != null && fp.HasValue && thumbnailFingerprint.HasValue && fp.Value == thumbnailFingerprint.Value) - { - // Visually identical - adopt metadata only - if (svcMedia != null) - { - currentMedia = svcMedia; - currentMediaKey = key; - } - optimisticActive = false; - skipPending = true; - } - } - if (skipPending) - { - if (img != null) { try { img.Dispose(); } catch { } } - continue; - } - - lock (mediaLock) - { - if (img != null) - { - // Queue as pending (replace any existing pending) - if (pendingImage != null) { try { pendingImage.Dispose(); } catch { } pendingImage = null; pendingFingerprint = null; pendingMediaKey = null; pendingMedia = null; } - - pendingImage = img; - pendingFingerprint = fp; - pendingMedia = svcMedia; - pendingMediaKey = key; - - // Do not set thumbnailImage here; animator will swap on flip - } - else - { - // No image decoded: if there's metadata but no bytes, adopt metadata if changed - // Only adopt metadata when svcMedia is non-null. Avoid clearing currentMedia when media lookup returned null - if ((svcBytes == null || svcBytes.Length == 0) && svcMedia != null && key != currentMediaKey && pendingMediaKey == null) - { - currentMedia = svcMedia; - currentMediaKey = key; - optimisticActive = false; - } - } - } - } - } - } + await Task.Delay(intervalMs, token).ConfigureAwait(false); } - catch (OperationCanceledException) { break; } - catch (Exception) { } - - int totalMs = (int)fetchInterval.TotalMilliseconds; - int waited = 0; - const int step = 250; - - while (waited < totalMs && !token.IsCancellationRequested) + catch (OperationCanceledException) { - int delay = Math.Min(step, totalMs - waited); - try { await Task.Delay(delay).ConfigureAwait(false); } catch { } - waited += delay; + break; } } }, token); } - /// - /// Stop the background fetch loop. If disposeCached is true, also free cached images and metadata. - /// - private void StopFetchLoop(bool disposeCached = false) + private void UpdateTextCache(float deltaTime) { - if (cts == null) + string title; + string artist; + + lock (mediaLock) { - // Still optionally free resources - if (disposeCached) - { - lock (mediaLock) - { - // If animator is mid-animation, keep images so flip can complete when UI resumes. - if (animator.State == MediaAnimator.AnimState.Idle) - { - if (pendingImage != null) { try { pendingImage.Dispose(); } catch { } pendingImage = null; pendingFingerprint = null; } - if (thumbnailImage != null) { try { thumbnailImage.Dispose(); } catch { } thumbnailImage = null; thumbnailFingerprint = null; } - if (previousImage != null) { try { previousImage.Dispose(); } catch { } previousImage = null; } - } + title = !string.IsNullOrEmpty(currentMedia?.Title) ? currentMedia!.Title! : "No media playing"; + artist = !string.IsNullOrEmpty(currentMedia?.Artist) ? currentMedia!.Artist! : "No media playing"; + } - pendingMediaKey = null; pendingMedia = null; - currentMedia = null; currentMediaKey = null; - } - } + SetTextBlobs(title, artist); + + isTitleScrolling = titleTextWidth > Math.Max(1f, titleClipRect.Width); + if (!isTitleScrolling) + { + titleScrollOffset = 0f; + titleScrollTimer = 0f; return; } - try + if (titleScrollTimer < TitleScrollDelay) { - cts.Cancel(); + titleScrollTimer += deltaTime; + return; } - catch { } - try + + titleScrollOffset += TitleScrollSpeed * deltaTime; + if (titleScrollOffset > titleTextWidth + 20f) { - cts.Dispose(); + titleScrollOffset = 0f; + titleScrollTimer = 0f; } - catch { } - cts = null; + } - lock (mediaLock) + private void SetTextBlobs(string title, string artist) + { + if (fullTitleText != title) { - // Only dispose pendingImage if animator is idle; otherwise keep it so the pending swap can complete when UI resumes - if (animator.State == MediaAnimator.AnimState.Idle) - { - if (pendingImage != null) - { - try { pendingImage.Dispose(); } catch { } - pendingImage = null; - pendingFingerprint = null; - } - } - - pendingMediaKey = null; - pendingMedia = null; + fullTitleText = title; + truncatedTitleText = DWText.Truncate(title, TitleTruncateChars); + ReplaceTextBlob(ref fullTitleBlob, fullTitleText, titleFont); + ReplaceTextBlob(ref truncatedTitleBlob, truncatedTitleText, titleFont); + titleTextWidth = titleFont.MeasureText(fullTitleText); + titleScrollOffset = 0f; + titleScrollTimer = 0f; + } - if (disposeCached) - { - if (animator.State == MediaAnimator.AnimState.Idle) - { - if (thumbnailImage != null) { try { thumbnailImage.Dispose(); } catch { } thumbnailImage = null; thumbnailFingerprint = null; } - if (previousImage != null) { try { previousImage.Dispose(); } catch { } previousImage = null; } - } - currentMedia = null; - currentMediaKey = null; - } + string nextArtist = DWText.Truncate(artist, ArtistTruncateChars); + if (artistText != nextArtist) + { + artistText = nextArtist; + ReplaceTextBlob(ref artistBlob, artistText, artistFont); } } - // Reset thumbnail/animation state for menu close/deactivation - private void ResetThumbnailState() + private void UpdateTimelineTextCache() { - lock (mediaLock) + int elapsedSecond = displayedElapsedInitialized + ? Math.Max(0, (int)Math.Floor(displayedElapsedSeconds)) + : -1; + int durationSecond = timelineDuration.HasValue && timelineDuration.Value.TotalSeconds > 0 + ? Math.Max(0, (int)Math.Floor(timelineDuration.Value.TotalSeconds)) + : -1; + + if (elapsedSecond == cachedTimelineElapsedSecond && + durationSecond == cachedTimelineDurationSecond) { - // Reset animator to idle - animatorReset(); - // Dispose and clear all images and fingerprints - if (thumbnailImage != null) { try { thumbnailImage.Dispose(); } catch { } thumbnailImage = null; thumbnailFingerprint = null; } - if (pendingImage != null) { try { pendingImage.Dispose(); } catch { } pendingImage = null; pendingFingerprint = null; } - if (previousImage != null) { try { previousImage.Dispose(); } catch { } previousImage = null; } - currentMedia = null; - currentMediaKey = null; - pendingMedia = null; - pendingMediaKey = null; - optimisticActive = false; - timelineFetchedOnce = false; - lastTimelineResync = DateTime.MinValue; + return; } - } - // Helper to reset animator state - private void animatorReset() - { - animator.ForceFinish(); + cachedTimelineElapsedSecond = elapsedSecond; + cachedTimelineDurationSecond = durationSecond; + + if (durationSecond > 0 && elapsedSecond >= 0) + { + elapsedSecond = Math.Min(elapsedSecond, durationSecond); + SetTimelineText( + FormatTimeSpanForDisplay(TimeSpan.FromSeconds(elapsedSecond)), + "-" + FormatTimeSpanForDisplay(TimeSpan.FromSeconds(Math.Max(0, durationSecond - elapsedSecond)))); + } + else + { + SetTimelineText("--:--", "--:--"); + } } - public override void Draw(SKCanvas canvas) + private void SetTimelineText(string left, string right) { - // Extra visibility guard - try + if (cachedTimelineLeftText != left) { - var home = Res.HomeMenu; - if (home == null) return; - if (home.currentBigMenuMode != HomeMenu.BigMenuMode.Media) return; - if (!RendererMain.Instance.MainIsland.IsHovering) return; + cachedTimelineLeftText = left; + ReplaceTextBlob(ref timelineLeftBlob, cachedTimelineLeftText, timelineFont); } - catch { return; } - if (!IsEnabled) return; - if (Parent != null && !Parent.IsEnabled) return; + if (cachedTimelineRightText != right) + { + cachedTimelineRightText = right; + ReplaceTextBlob(ref timelineRightBlob, cachedTimelineRightText, timelineFont); + cachedTimelineRightTextWidth = timelineFont.MeasureText(cachedTimelineRightText); + layoutTimelineRightTextWidth = -1f; + } + } - var rr = GetRect(); - var rect = rr.Rect; - if (rect.Width <= 0 || rect.Height <= 0) return; + private void ReplaceTextBlob(ref SKTextBlob? blob, string text, SKFont font) + { + blob?.Dispose(); + blob = SKTextBlob.Create(text ?? string.Empty, font); + } - float maxThumb = Math.Min(90f, rect.Width * 0.35f); - float thumbSize = Math.Min(Math.Min(rect.Height * 2f, rect.Height * 1f), maxThumb); - thumbSize = Math.Max(12f, thumbSize); - float thumbRadius = Math.Max(12f, thumbSize * 0.18f); - SKRect thumbRect = SKRect.Create(rect.Left, rect.Top, thumbSize, thumbSize); + private static string FormatTimeSpanForDisplay(TimeSpan ts) + { + if (ts.TotalHours >= 1) + return string.Format("{0:D2}:{1:D2}:{2:D2}", (int)ts.TotalHours, ts.Minutes, ts.Seconds); - string title = "No media playing"; - string artist = "No media playing"; - SKImage? img = null; + return string.Format("{0:D2}:{1:D2}", (int)ts.TotalMinutes, ts.Seconds); + } + private void UpdateVisualiserState() + { + bool hasMedia; lock (mediaLock) { - if (currentMedia != null) - { - title = currentMedia.Title ?? "No media playing"; - artist = currentMedia.Artist ?? "No media playing"; - } - img = thumbnailImage; + hasMedia = currentMedia != null || thumbnailImage != null || pendingImage != null; } - if (img == null && string.IsNullOrEmpty(title) && string.IsNullOrEmpty(artist)) return; + visualiser.SilentSetActive(hasMedia); + SetVisualiserCapture(hasMedia && GetEffectivePlayingState()); + } - SKImage? displayImg = img; - SKImage? prevImg = previousImage; + public override bool WantsRealtimeUpdate + { + get + { + if (!ShouldRenderMediaPlayer()) return false; - var squirclePath = BuildSuperellipsePath(thumbRect, 30f, 1f); + return userIsSeeking || + mouseDownOverTimeline || + animator.State != MediaAnimator.AnimState.Idle || + Math.Abs(thumbnailAnim - (GetEffectivePlayingState() ? 1f : 0f)) > 0.01f || + Math.Abs(timelineExtraHeight - (userIsSeeking ? 3f : (isHoveringOverTimeline ? 6f : 0f))) > 0.05f; + } + } - try + public override bool WantsContinuousUpdate + { + get { - float flipScale = animator.GetFlipScale(); - bool doFlip = animator.IsFlipping; - int save = canvas.Save(); - - // Shrink and dim thumbnail if paused, animated - float thumbScale = 0.6f + 0.4f * thumbnailAnim; // 0.6 (paused) to 1.0 (playing) - float dimAlpha = (1f - thumbnailAnim) * 120f; // 0 (playing) to 120 (paused) - float centerX = thumbRect.MidX; - float centerY = thumbRect.MidY; + if (!ShouldRenderMediaPlayer()) return false; + return GetEffectivePlayingState() || isTitleScrolling || WantsRealtimeUpdate; + } + } - if (doFlip) - { - float cx = thumbRect.MidX; - float cy = thumbRect.MidY; - canvas.Translate(cx, cy); - canvas.Scale(flipScale * thumbScale, thumbScale); - var localRect = SKRect.Create(-thumbSize / 2f, -thumbSize / 2f, thumbSize, thumbSize); - var localPath = BuildSuperellipsePath(localRect, 30f, 1f); - canvas.Save(); - canvas.ClipPath(localPath, antialias: Settings.AntiAliasing); - - var paint = GetPaint(); - paint.IsAntialias = Settings.AntiAliasing; - paint.IsStroke = false; - paint.ImageFilter = animator.BlurAmount > 0f ? SKImageFilter.CreateBlur(animator.BlurAmount, animator.BlurAmount) : null; - paint.BlendMode = SKBlendMode.SrcOver; - - if (displayImg != null) - { - try { canvas.DrawImage(displayImg, localRect, paint); } catch { } - if (dimAlpha > 0.5f) - { - using var dimPaint = GetPaint(); - dimPaint.Color = new SKColor(0, 0, 0, (byte)dimAlpha); - canvas.DrawRect(localRect, dimPaint); - } - } - else - { - using var p = GetPaint(); - p.IsAntialias = Settings.AntiAliasing; - p.IsStroke = false; - p.Color = GetColor(Theme.WidgetBackground.Override(a: 0.06f)).Value(); - p.ImageFilter = animator.BlurAmount > 0f ? SKImageFilter.CreateBlur(animator.BlurAmount, animator.BlurAmount) : null; - p.BlendMode = SKBlendMode.SrcOver; - canvas.DrawRoundRect(new SKRoundRect(localRect, thumbRadius), p); - } + private void EnsureLayout() + { + var nextRect = GetRect().Rect; + bool baseChanged = layoutDirty || !RectsClose(layoutRect, nextRect); + bool timelineChanged = + baseChanged || + Math.Abs(layoutTimelineExtraHeight - timelineExtraHeight) > 0.001f || + Math.Abs(layoutTimelineRightTextWidth - cachedTimelineRightTextWidth) > 0.001f; - canvas.Restore(); - canvas.RestoreToCount(save); - } - else - { - canvas.Save(); - canvas.Translate(centerX, centerY); - canvas.Scale(thumbScale, thumbScale); - canvas.Translate(-centerX, -centerY); - canvas.ClipPath(squirclePath, antialias: Settings.AntiAliasing); - - var paint = GetPaint(); - paint.IsAntialias = Settings.AntiAliasing; - paint.IsStroke = false; - paint.ImageFilter = animator.BlurAmount > 0f ? SKImageFilter.CreateBlur(animator.BlurAmount, animator.BlurAmount) : null; - paint.BlendMode = SKBlendMode.SrcOver; - if (displayImg != null) - { - try { canvas.DrawImage(displayImg, thumbRect, paint); } catch { } - if (dimAlpha > 0.5f) - { - using var dimPaint = GetPaint(); - dimPaint.Color = new SKColor(0, 0, 0, (byte)dimAlpha); - canvas.DrawRect(thumbRect, dimPaint); - } - } - else - { - using var p = GetPaint(); - p.IsAntialias = Settings.AntiAliasing; - p.IsStroke = false; - p.Color = GetColor(Theme.WidgetBackground.Override(a: 0.06f)).Value(); - p.ImageFilter = animator.BlurAmount > 0f ? SKImageFilter.CreateBlur(animator.BlurAmount, animator.BlurAmount) : null; - p.BlendMode = SKBlendMode.SrcOver; - canvas.DrawRoundRect(new SKRoundRect(thumbRect, thumbRadius), p); - } + if (!baseChanged && !timelineChanged) return; - canvas.Restore(); - } + if (baseChanged) + { + layoutDirty = false; + layoutRect = nextRect; + + float maxThumb = Math.Min(90f, layoutRect.Width * 0.35f); + float thumbSize = Math.Max(12f, Math.Min(layoutRect.Height, maxThumb)); + thumbnailRect = SKRect.Create(layoutRect.Left, layoutRect.Top, thumbSize, thumbSize); + localThumbnailRect = SKRect.Create(-thumbSize / 2f, -thumbSize / 2f, thumbSize, thumbSize); + + thumbnailPath?.Dispose(); + thumbnailPath = BuildSuperellipsePath(thumbnailRect, 30f, 1f); + localThumbnailPath?.Dispose(); + localThumbnailPath = BuildSuperellipsePath(localThumbnailRect, 30f, 1f); + + textX = thumbnailRect.Right + 14f; + titleBaseline = layoutRect.Top + 16f + TitleTextSize; + artistBaseline = titleBaseline + ArtistTextSize + 6f; + float titleWidth = Math.Max(1f, layoutRect.Width - (textX - layoutRect.Left) - 45f); + titleClipRect = SKRect.Create(textX, layoutRect.Top + 16f, titleWidth, TitleTextSize + 4f); + + float barWidth = Math.Max(1f, layoutRect.Width - 2f * TimelineSidePadding); + float barX = layoutRect.Left + (layoutRect.Width - barWidth) / 2f; + timelineBarBaseY = layoutRect.Top + 95f + TimelineBarPadding; + if (timelineBarBaseY + TimelineHeight > layoutRect.Bottom) + timelineBarBaseY = layoutRect.Bottom - TimelineHeight - TimelineBarPadding; + + timelineBaseRect = SKRect.Create(barX, timelineBarBaseY, barWidth, TimelineHeight); } - catch { } - // Draw texts and timeline (reuse existing code from earlier) - float textX = thumbRect.Right + 14f; - float textY = rect.Top + 16f; + layoutTimelineExtraHeight = timelineExtraHeight; + layoutTimelineRightTextWidth = cachedTimelineRightTextWidth; + float drawHeight = TimelineHeight + timelineExtraHeight; + float drawY = timelineBarBaseY + 3.5f - ((drawHeight - TimelineHeight) / 2f); + timelineBarRect = SKRect.Create(timelineBaseRect.Left, drawY, timelineBaseRect.Width, drawHeight); - var titlePaint = GetPaint(); - titlePaint.IsStroke = false; - titlePaint.TextSize = 14f; - titlePaint.Typeface = Resources.Res.SFProBold; - titlePaint.Color = GetColor(Theme.TextMain).Value(); + timelineTextBaseline = timelineBarBaseY + TimelineHeight + TimelineTextSize - 6f; + timelineLeftX = timelineBaseRect.Left - TimelineSidePadding + 4f; + timelineRightX = timelineBaseRect.Right + TimelineSidePadding - cachedTimelineRightTextWidth - 4f; + } - var artistPaint = GetPaint(); - artistPaint.IsStroke = false; - artistPaint.TextSize = 12f; - artistPaint.Typeface = Resources.Res.SFProRegular; - artistPaint.Color = GetColor(Theme.TextSecond).Value(); + private static bool RectsClose(SKRect a, SKRect b) + { + return Math.Abs(a.Left - b.Left) <= 0.001f && + Math.Abs(a.Top - b.Top) <= 0.001f && + Math.Abs(a.Width - b.Width) <= 0.001f && + Math.Abs(a.Height - b.Height) <= 0.001f; + } - if (!string.IsNullOrEmpty(fullTitleText)) + private void DrawThumbnail(SKCanvas canvas, SKImage? image) + { + float flipScale = animator.GetFlipScale(); + bool isFlipping = animator.IsFlipping; + float thumbScale = 0.8f + 0.2f * thumbnailAnim; + float dimAlpha = (1f - thumbnailAnim) * 120f; + float blurAmount = animator.BlurAmount; + + SKImageFilter? blurFilter = null; + if (blurAmount > 0f) { - float maxWidth = rect.Width - (textX - rect.Left) - 45f; - if (isTitleScrolling) - { - canvas.Save(); - canvas.ClipRect(SKRect.Create(textX, textY, maxWidth, titlePaint.TextSize + 2f), antialias: Settings.AntiAliasing); - float xPos = textX - titleScrollOffset; - canvas.DrawText(fullTitleText, xPos, textY + titlePaint.TextSize, titlePaint); - if (xPos + titleTextWidth < textX + maxWidth) - { - canvas.DrawText(fullTitleText, xPos + titleTextWidth + 20f, textY + titlePaint.TextSize, titlePaint); - } - canvas.Restore(); - } - else - { - var truncated = DWText.Truncate(fullTitleText, titleScrollCharThreshold); - canvas.DrawText(truncated, textX, textY + titlePaint.TextSize, titlePaint); - } + blurFilter = SKImageFilter.CreateBlur(blurAmount, blurAmount); + thumbnailPaint.ImageFilter = blurFilter; + placeholderPaint.ImageFilter = blurFilter; } - if (!string.IsNullOrEmpty(artist)) - { - var displayArtist = DWText.Truncate(artist, 45); - canvas.DrawText(displayArtist, textX, textY + titlePaint.TextSize + artistPaint.TextSize + 6f, artistPaint); - } + thumbnailPaint.IsAntialias = Settings.AntiAliasing; + placeholderPaint.IsAntialias = Settings.AntiAliasing; + placeholderPaint.Color = GetColor(Theme.WidgetBackground.Override(a: 0.06f)).Value(); + dimPaint.Color = new SKColor(0, 0, 0, (byte)Math.Clamp(dimAlpha, 0f, 255f)); - try + if (isFlipping) { - float barWidth = rect.Width - 2 * timelineSidePadding; - float barX = rect.Left + (rect.Width - barWidth) / 2f; - float barY = rect.Top + 95f + timelineBarPadding; - if (barY + timelineHeight > rect.Bottom) barY = rect.Bottom - timelineHeight - timelineBarPadding; + canvas.Save(); + canvas.Translate(thumbnailRect.MidX, thumbnailRect.MidY); + canvas.Scale(flipScale * thumbScale, thumbScale); - float drawTimelineHeight = timelineHeight + timelineExtraHeight; + if (localThumbnailPath != null) + canvas.ClipPath(localThumbnailPath, antialias: Settings.AntiAliasing); - // If we have a DWProgressBarEx instance, position it and draw it - if (timelineBar != null) - { - // Set size and local position relative to this object's rect - timelineBar.Size = new Vec2(barWidth, drawTimelineHeight); - timelineBar.LocalPosition = new Vec2(barX - rect.Left - 40f, barY - rect.Top + 3.5f); - timelineBar.CornerRadius = drawTimelineHeight / 2f; - // timelineBar target value is driven from Update to respect locking; do not set Value here. - timelineBar.ForegroundColor = timelineFgColor.Override(a: 0.6f); - timelineBar.BackgroundColor = Theme.WidgetBackground.Override(a: 0.04f); - // If there's no media playing, lock and force the bar to zero immediately - if (currentMedia == null) - { - timelineBar.IsLocked = true; - timelineBar.ForceSetImmediate(0f); - } - else - { - timelineBar.IsLocked = false; - // Drive the target value so smoothing animates the visual - timelineBar.ForceSetValue(displayFill); - } + DrawThumbnailContents(canvas, image, localThumbnailRect, placeholderPaint, thumbnailPaint); + if (dimAlpha > 0.5f) + canvas.DrawRect(localThumbnailRect, dimPaint); - // Draw the progress bar as a child at the computed location - timelineBar.Draw(canvas); - } + canvas.Restore(); + } + else + { + canvas.Save(); + canvas.Translate(thumbnailRect.MidX, thumbnailRect.MidY); + canvas.Scale(thumbScale, thumbScale); + canvas.Translate(-thumbnailRect.MidX, -thumbnailRect.MidY); - string leftText; - string rightText; + if (thumbnailPath != null) + canvas.ClipPath(thumbnailPath, antialias: Settings.AntiAliasing); - if (timelineDuration.HasValue && timelineDuration.Value.TotalSeconds > 0) - { - var leftTs = TimeSpan.FromSeconds(displayedElapsedSeconds); - var rightRemain = timelineDuration.Value - TimeSpan.FromSeconds(displayedElapsedSeconds); - leftText = FormatTimeSpanForDisplay(leftTs); - rightText = "-" + FormatTimeSpanForDisplay(rightRemain); - } - else - { - leftText = "--:--"; - rightText = "--:--"; - } + DrawThumbnailContents(canvas, image, thumbnailRect, placeholderPaint, thumbnailPaint); + if (dimAlpha > 0.5f) + canvas.DrawRect(thumbnailRect, dimPaint); - using (var paint = GetPaint()) - { - paint.IsStroke = false; - paint.IsAntialias = Settings.AntiAliasing; - paint.Color = GetColor(timelineTextColor).Value(); - paint.TextSize = timelineTextSize; - paint.Typeface = Resources.Res.SFProRegular; - - float timelineTextY = barY + timelineHeight + timelineTextSize - 10f; - float leftX = barX - timelineSidePadding + 4f; - canvas.DrawText(leftText, leftX, timelineTextY, paint); - - float rightTextWidth = paint.MeasureText(rightText); - float rightX = barX + barWidth + timelineSidePadding - rightTextWidth - 4f; - canvas.DrawText(rightText, rightX, timelineTextY, paint); - } + canvas.Restore(); } - catch { } + + thumbnailPaint.ImageFilter = null; + placeholderPaint.ImageFilter = null; + blurFilter?.Dispose(); } - public override void OnDestroy() + private void DrawThumbnailContents(SKCanvas canvas, SKImage? image, SKRect rect, SKPaint placeholder, SKPaint imagePaint) { - base.OnDestroy(); + if (image != null) + { + canvas.DrawImage(image, rect, imagePaint); + return; + } - try { MediaThumbnailService.Instance.ThumbnailChanged -= OnThumbnailChanged; } catch { } - isThumbnailSubscribed = false; + canvas.DrawRoundRect(new SKRoundRect(rect, Math.Max(12f, rect.Width * 0.18f)), placeholder); + } + + private void DrawText(SKCanvas canvas) + { + titlePaint.IsAntialias = Settings.AntiAliasing; + artistPaint.IsAntialias = Settings.AntiAliasing; + titlePaint.Color = GetColor(Theme.TextMain).Value(); + artistPaint.Color = GetColor(Theme.TextSecond).Value(); - StopFetchLoop(disposeCached: true); - // Reset thumbnail/animation state - ResetThumbnailState(); + if (isTitleScrolling && fullTitleBlob != null) + { + canvas.Save(); + canvas.ClipRect(titleClipRect, antialias: Settings.AntiAliasing); + float x = textX - titleScrollOffset; + canvas.DrawText(fullTitleBlob, x, titleBaseline, titlePaint); + + if (x + titleTextWidth < titleClipRect.Right) + canvas.DrawText(fullTitleBlob, x + titleTextWidth + 20f, titleBaseline, titlePaint); + + canvas.Restore(); + } + else if (truncatedTitleBlob != null) + { + canvas.DrawText(truncatedTitleBlob, textX, titleBaseline, titlePaint); + } + + if (artistBlob != null) + canvas.DrawText(artistBlob, textX, artistBaseline, artistPaint); } + private void DrawTimeline(SKCanvas canvas, bool hasMedia) + { + timelineTrackPaint.IsAntialias = Settings.AntiAliasing; + timelineFillPaint.IsAntialias = Settings.AntiAliasing; + timelineTextPaint.IsAntialias = Settings.AntiAliasing; + + timelineTrackPaint.Color = GetColor(Theme.WidgetBackground.Override(a: 0.04f)).Value(); + timelineFillPaint.Color = GetColor(Theme.TextMain.Override(a: 0.6f)).Value(); + timelineTextPaint.Color = GetColor(Theme.TextMain.Override(a: 0.55f)).Value(); + + float radius = timelineBarRect.Height / 2f; + canvas.DrawRoundRect(new SKRoundRect(timelineBarRect, radius), timelineTrackPaint); + + if (hasMedia && displayFill > 0.001f) + { + var fillRect = SKRect.Create( + timelineBarRect.Left, + timelineBarRect.Top, + Math.Max(radius, timelineBarRect.Width * Math.Clamp(displayFill, 0f, 1f)), + timelineBarRect.Height); + canvas.DrawRoundRect(new SKRoundRect(fillRect, radius), timelineFillPaint); + } + + if (timelineLeftBlob != null) + canvas.DrawText(timelineLeftBlob, timelineLeftX, timelineTextBaseline, timelineTextPaint); + + if (timelineRightBlob != null) + canvas.DrawText(timelineRightBlob, timelineRightX, timelineTextBaseline, timelineTextPaint); + } } } diff --git a/DynamicWin/UI/UIElements/DWButton.cs b/DynamicWin/UI/UIElements/DWButton.cs index 26641b7..cd50bea 100644 --- a/DynamicWin/UI/UIElements/DWButton.cs +++ b/DynamicWin/UI/UIElements/DWButton.cs @@ -44,21 +44,23 @@ public DWButton(UIObject? parent, Vec2 position, Vec2 size, Action clickCallback Color = normalColor; } + public override bool WantsRealtimeUpdate + { + get + { + var targetSize = initialScale * GetTargetScaleMultiplier(); + return IsMouseDown || + Math.Abs(Size.X - targetSize.X) > 0.1f || + Math.Abs(Size.Y - targetSize.Y) > 0.1f; + } + } + public override void Update(float deltaTime) { base.Update(deltaTime); Vec2 currentSize = initialScale; - scaleMultiplier = Vec2.one; - - if (IsHovering && !IsMouseDown) - scaleMultiplier *= hoverScaleMulti; - else if (IsMouseDown) - scaleMultiplier *= clickScaleMulti; - else if (!IsHovering && !IsMouseDown) - scaleMultiplier *= normalScaleMulti; - else - scaleMultiplier *= normalScaleMulti; + scaleMultiplier = GetTargetScaleMultiplier(); currentSize *= scaleMultiplier; @@ -74,6 +76,16 @@ public override void Update(float deltaTime) Color = GetColor(Col.Lerp(Color, normalColor, colorSmoothingSpeed * deltaTime)); } + protected Vec2 GetTargetScaleMultiplier() + { + if (IsHovering && !IsMouseDown) + return hoverScaleMulti; + if (IsMouseDown) + return clickScaleMulti; + + return normalScaleMulti; + } + public override void OnMouseUp() { clickCallback?.Invoke(); diff --git a/DynamicWin/UI/UIElements/DWImage.cs b/DynamicWin/UI/UIElements/DWImage.cs index 303a93c..628220d 100644 --- a/DynamicWin/UI/UIElements/DWImage.cs +++ b/DynamicWin/UI/UIElements/DWImage.cs @@ -12,9 +12,19 @@ namespace DynamicWin.UI.UIElements { public class DWImage : UIObject { - private SKBitmap image; + private SKBitmap? image; - public SKBitmap Image { get { return image; } set => image = value; } + public SKBitmap? Image + { + get { return image; } + set + { + if (ReferenceEquals(image, value)) return; + + image = value; + MarkGpuDirty(); + } + } public bool maskOwnRect = false; public bool allowIconThemeColor = true; @@ -29,10 +39,10 @@ public DWImage(UIObject? parent, SKBitmap sprite, Vec2 position, Vec2 size, UIAl public override void Draw(SKCanvas canvas) { - var paint = GetPaint(); - if (image == null) return; + using var paint = GetPaint(); + if (allowIconThemeColor) { var imageFilter = SKImageFilter.CreateBlendMode(SKBlendMode.DstIn, diff --git a/DynamicWin/UI/UIElements/DWText.cs b/DynamicWin/UI/UIElements/DWText.cs index 854b556..6816dd6 100644 --- a/DynamicWin/UI/UIElements/DWText.cs +++ b/DynamicWin/UI/UIElements/DWText.cs @@ -14,12 +14,37 @@ public class DWText : UIObject public string Text { get { return text; } set { SetText(value); } } private float textSize = 24; - public float TextSize { get => textSize; set => textSize = value; } + public float TextSize + { + get => textSize; + set + { + if (NearlyEqual(textSize, value)) return; + + textSize = value; + InvalidateTextCache(); + } + } - private Vec2 textBounds; + private Vec2 textBounds = Vec2.zero; private SKTypeface font; - public SKTypeface Font { get => font; set => font = value; } + public SKTypeface Font + { + get => font; + set + { + if (ReferenceEquals(font, value)) return; + + font = value; + InvalidateTextCache(); + } + } + + private SKTextBlob? cachedBlob; + private SKFont? cachedFont; + private Vec2 drawAlignmentSize = Vec2.one; + private bool textCacheDirty = true; public Vec2 TextBounds { @@ -35,47 +60,83 @@ public DWText(UIObject? parent, string text, Vec2 position, UIAlignment alignmen this.text = text; Color = Theme.TextMain; font = Resources.Res.SFProRegular; + UseGpuCaching = true; } public override void Draw(SKCanvas canvas) { - var paint = GetPaint(); + EnsureTextCache(); + + using var paint = GetPaint(); paint.Color = Color.Value(); paint.TextSize = textSize; paint.Typeface = font; - // Measure the width of the text - Size.X = paint.MeasureText(text); - - // Measure the height of the text - var fontMetrics = paint.FontMetrics; - Size.Y = fontMetrics.Descent + fontMetrics.Ascent; - - SKTextBlob blob = SKTextBlob.Create(text, new SKFont(paint.Typeface, textSize)); - - if (blob != null) + if (cachedBlob != null) { - canvas.DrawText(blob, Position.X, Position.Y, paint); - textBounds = new Vec2(blob.Bounds.Width, blob.Bounds.Height); + var drawPosition = GetScreenPosFromRawPosition(RawPosition, drawAlignmentSize) + LocalPosition; + canvas.DrawText(cachedBlob, drawPosition.X, drawPosition.Y, paint); } - Size = textBounds; - //canvas.DrawRoundRect(GetRect(), paint); } public Vec2 GetBoundsForString(string text) { - SKTextBlob blob = SKTextBlob.Create(text, new SKFont(Font, textSize)); + using var font = new SKFont(Font, textSize); + using var blob = SKTextBlob.Create(text, font); - return new Vec2(blob.Bounds.Width, blob.Bounds.Height); + return blob == null ? Vec2.zero : new Vec2(blob.Bounds.Width, blob.Bounds.Height); + } + + private void EnsureTextCache() + { + if (!textCacheDirty) return; + + cachedBlob?.Dispose(); + cachedFont?.Dispose(); + + cachedFont = new SKFont(font, textSize); + cachedBlob = SKTextBlob.Create(text ?? string.Empty, cachedFont); + + using var measurePaint = GetPaint(); + measurePaint.TextSize = textSize; + measurePaint.Typeface = font; + + var fontMetrics = measurePaint.FontMetrics; + drawAlignmentSize = new Vec2( + measurePaint.MeasureText(text ?? string.Empty), + fontMetrics.Descent + fontMetrics.Ascent + ); + + if (cachedBlob != null) + { + textBounds = new Vec2(cachedBlob.Bounds.Width, cachedBlob.Bounds.Height); + Size = textBounds; + } + else + { + textBounds = Vec2.zero; + Size = Vec2.one; + } + + textCacheDirty = false; + } + + private void InvalidateTextCache() + { + textCacheDirty = true; + MarkGpuDirty(); } Animator changeTextAnim; public void SilentSetText(string text) { + if (this.text == text) return; + this.text = text; + InvalidateTextCache(); } public void SetText(string text) @@ -93,17 +154,17 @@ public void SetText(string text) { float t = Easings.EaseInQuint(x * 2); - textSize = Mathf.Lerp(ogTextSize, ogTextSize / 1.5f, t); + TextSize = Mathf.Lerp(ogTextSize, ogTextSize / 1.5f, t); localBlurAmount = Mathf.Lerp(0, 10, t); Alpha = Mathf.Lerp(1, 0, x); } else { - this.text = text; + SilentSetText(text); float t = Easings.EaseOutQuint((x - 0.5f) * 2); - textSize = Mathf.Lerp(ogTextSize / 2.5f, ogTextSize, t); + TextSize = Mathf.Lerp(ogTextSize / 2.5f, ogTextSize, t); localBlurAmount = Mathf.Lerp(10, 0, t); Alpha = Mathf.Lerp(0, 1, x); } @@ -113,12 +174,21 @@ public void SetText(string text) changeTextAnim.Start(); changeTextAnim.onAnimationEnd += () => { - this.text = text; - textSize = ogTextSize; + SilentSetText(text); + TextSize = ogTextSize; DestroyLocalObject(changeTextAnim); }; } + public override void OnDestroy() + { + base.OnDestroy(); + cachedBlob?.Dispose(); + cachedBlob = null; + cachedFont?.Dispose(); + cachedFont = null; + } + public static string Truncate(string value, int maxLength) { if (string.IsNullOrEmpty(value)) return value; diff --git a/DynamicWin/UI/UIElements/DWTextButton.cs b/DynamicWin/UI/UIElements/DWTextButton.cs index f93bfb9..684b30c 100644 --- a/DynamicWin/UI/UIElements/DWTextButton.cs +++ b/DynamicWin/UI/UIElements/DWTextButton.cs @@ -25,22 +25,25 @@ public DWTextButton(UIObject? parent, string buttonText, Vec2 position, Vec2 siz Text.TextSize = normalTextSize; } + public override bool WantsRealtimeUpdate + { + get + { + float targetTextSize = GetTargetTextSize(); + return base.WantsRealtimeUpdate || Math.Abs(Text.TextSize - targetTextSize) > 0.05f; + } + } + public override void Update(float deltaTime) { base.Update(deltaTime); - float currentTextSize = normalTextSize; - - if (IsHovering && !IsMouseDown) - currentTextSize *= hoverScaleMulti.Magnitude; - else if (IsMouseDown) - currentTextSize *= clickScaleMulti.Magnitude; - else if (!IsHovering && !IsMouseDown) - currentTextSize *= normalScaleMulti.Magnitude; - else - currentTextSize *= normalScaleMulti.Magnitude; + Text.TextSize = Mathf.Lerp(Text.TextSize, GetTargetTextSize(), textSizeSmoothSpeed * deltaTime); + } - Text.TextSize = Mathf.Lerp(Text.TextSize, currentTextSize, textSizeSmoothSpeed * deltaTime); + private float GetTargetTextSize() + { + return normalTextSize * GetTargetScaleMultiplier().Magnitude; } } } diff --git a/DynamicWin/UI/UIElements/DWTextImageButton.cs b/DynamicWin/UI/UIElements/DWTextImageButton.cs index 6bab809..142f371 100644 --- a/DynamicWin/UI/UIElements/DWTextImageButton.cs +++ b/DynamicWin/UI/UIElements/DWTextImageButton.cs @@ -37,24 +37,21 @@ public DWTextImageButton(UIObject? parent, SKBitmap image, string buttonText, Ve Text.TextSize = normalTextSize; } + public override bool WantsRealtimeUpdate + { + get + { + float targetTextSize = normalTextSize * GetTargetScaleMultiplier().Magnitude; + return base.WantsRealtimeUpdate || Math.Abs(Text.TextSize - targetTextSize) > 0.05f; + } + } + public override void Update(float deltaTime) { base.Update(deltaTime); - float currentTextSize = normalTextSize; - Image.Size = Vec2.one * Size.Y * imageScale; - - if (IsHovering && !IsMouseDown) - currentTextSize *= hoverScaleMulti.Magnitude; - else if (IsMouseDown) - currentTextSize *= clickScaleMulti.Magnitude; - else if (!IsHovering && !IsMouseDown) - currentTextSize *= normalScaleMulti.Magnitude; - else - currentTextSize *= normalScaleMulti.Magnitude; - - Text.TextSize = Mathf.Lerp(Text.TextSize, currentTextSize, textSizeSmoothSpeed * deltaTime); + Text.TextSize = Mathf.Lerp(Text.TextSize, normalTextSize * GetTargetScaleMultiplier().Magnitude, textSizeSmoothSpeed * deltaTime); } } } diff --git a/DynamicWin/UI/UIElements/IslandObject.cs b/DynamicWin/UI/UIElements/IslandObject.cs index 4e0ceaa..5b9e9c6 100644 --- a/DynamicWin/UI/UIElements/IslandObject.cs +++ b/DynamicWin/UI/UIElements/IslandObject.cs @@ -65,6 +65,28 @@ public enum IslandMode { Island, Notch }; _morphT = Settings.IslandMode == IslandMode.Notch ? 1f : 0f; } + public override bool WantsRealtimeUpdate + { + get + { + float targetMorph = (Settings.IslandMode == IslandMode.Notch) ? 1f : 0f; + float targetSquircleState = (_morphT > 0.5f) + ? 1f + : (IsHovering ? 1f : (Size.Y > 20f ? 1f : 0f)); + float targetY = Mathf.Lerp((mode == IslandMode.Island ? 7.5f : 15f), -2.5f, _morphT); + float targetShadowStrength = IsHovering ? 0.75f : 0.25f; + float targetShadowSize = IsHovering ? 35f : 7.5f; + + return Math.Abs(_morphT - targetMorph) > 0.001f || + Math.Abs(cornerSquircleT - targetSquircleState) > 0.001f || + Math.Abs(LocalPosition.Y - targetY) > 0.1f || + Math.Abs(dropShadowStrength - targetShadowStrength) > 0.001f || + Math.Abs(dropShadowSize - targetShadowSize) > 0.1f || + Math.Abs(Size.X - currSize.X) > 0.25f || + Math.Abs(Size.Y - currSize.Y) > 0.25f; + } + } + public override void Update(float deltaTime) { base.Update(deltaTime); @@ -347,4 +369,4 @@ public SKPath GetIslandPath() ); } } -} \ No newline at end of file +} diff --git a/DynamicWin/UI/UIObject.cs b/DynamicWin/UI/UIObject.cs index c1fc19f..5b18662 100644 --- a/DynamicWin/UI/UIObject.cs +++ b/DynamicWin/UI/UIObject.cs @@ -23,6 +23,8 @@ public class UIObject public Vec2 LocalPosition { get => localPosition; set => localPosition = value; } public Vec2 Anchor { get => anchor; set => anchor = value; } + private const float DirtyFloatTolerance = 0.001f; + public Vec2 Size { get @@ -38,24 +40,41 @@ public Vec2 Size } set { + float nextX; + float nextY; + // 59xa: what even if (value == null) { - size = Vec2.one ?? new Vec2(1f, 1f); + nextX = 1f; + nextY = 1f; } else { - size = new Vec2( - Math.Max(1f, value.X), - Math.Max(1f, value.Y) - ); + nextX = Math.Max(1f, value.X); + nextY = Math.Max(1f, value.Y); } + if (size != null && NearlyEqual(size.X, nextX) && NearlyEqual(size.Y, nextY)) + return; + + size = new Vec2(nextX, nextY); MarkGpuDirty(); } } - public Col Color { get => new Col(color.r, color.g, color.b, color.a * Alpha); set { color = value; MarkGpuDirty(); } } + public Col Color + { + get => new Col(color.r, color.g, color.b, color.a * Alpha); + set + { + if (value == null) value = Col.Transparent; + if (ColorsClose(color, value)) return; + + color = value; + MarkGpuDirty(); + } + } private bool isHovering = false; private bool isMouseDown = false; @@ -83,7 +102,17 @@ public Vec2 Size private float pAlpha = 1f; private float oAlpha = 1f; - public float Alpha { get => (float) Math.Min(pAlpha, Math.Min(oAlpha, RendererMain.Instance.alphaOverride)); set { oAlpha = value; MarkGpuDirty(); } } + public float Alpha + { + get => (float)Math.Min(pAlpha, Math.Min(oAlpha, RendererMain.Instance.alphaOverride)); + set + { + if (NearlyEqual(oAlpha, value)) return; + + oAlpha = value; + MarkGpuDirty(); + } + } protected void AddLocalObject(UIObject obj) { @@ -301,31 +330,33 @@ public void UpdateCall(float deltaTime) if (canInteract) { - var rect = SKRect.Create(RendererMain.CursorPosition.X, RendererMain.CursorPosition.Y, 1, 1); + var cursor = RendererMain.CursorPosition; + bool leftMouseDown = RendererMain.IsLeftMouseButtonDown; + var rect = SKRect.Create(cursor.X, cursor.Y, 1, 1); isHovering = GetInteractionRect().Contains(rect); - if (!isGlobalMouseDown && Mouse.LeftButton == MouseButtonState.Pressed) + if (!isGlobalMouseDown && leftMouseDown) { isGlobalMouseDown = true; OnGlobalMouseDown(); } - else if (isGlobalMouseDown && !(Mouse.LeftButton == MouseButtonState.Pressed)) + else if (isGlobalMouseDown && !leftMouseDown) { isGlobalMouseDown = false; OnGlobalMouseUp(); } - if (IsHovering && !IsMouseDown && Mouse.LeftButton == MouseButtonState.Pressed) + if (IsHovering && !IsMouseDown && leftMouseDown) { IsMouseDown = true; OnMouseDown(); } - else if (IsHovering && IsMouseDown && !(Mouse.LeftButton == MouseButtonState.Pressed)) + else if (IsHovering && IsMouseDown && !leftMouseDown) { IsMouseDown = false; OnMouseUp(); } - else if (IsMouseDown && !(Mouse.LeftButton == MouseButtonState.Pressed)) + else if (IsMouseDown && !leftMouseDown) { IsMouseDown = false; } @@ -347,6 +378,41 @@ public void UpdateCall(float deltaTime) public virtual void Update(float deltaTime) { } + public virtual bool WantsRealtimeUpdate => false; + public virtual bool WantsContinuousUpdate => false; + + public bool SubtreeWantsRealtimeUpdate() + { + if (!isEnabled) return false; + if (WantsRealtimeUpdate) return true; + if (!drawLocalObjects) return false; + + for (int i = 0; i < localObjects.Count; i++) + { + var obj = localObjects[i]; + if (obj != null && obj.SubtreeWantsRealtimeUpdate()) + return true; + } + + return false; + } + + public bool SubtreeWantsContinuousUpdate() + { + if (!isEnabled) return false; + if (WantsContinuousUpdate || WantsRealtimeUpdate) return true; + if (!drawLocalObjects) return false; + + for (int i = 0; i < localObjects.Count; i++) + { + var obj = localObjects[i]; + if (obj != null && obj.SubtreeWantsContinuousUpdate()) + return true; + } + + return false; + } + // GPU caching fields private SKImage? gpuCache = null; private bool gpuCacheDirty = true; @@ -355,8 +421,11 @@ public virtual void Update(float deltaTime) { } // Flag to indicate Draw() already rendered full subtree (so DrawCall won't draw children again) private bool lastDrawRenderedSubtree = false; - private void MarkGpuDirty() + protected void MarkGpuDirty() { + if (gpuCacheDirty && gpuCache == null) + return; + gpuCacheDirty = true; try { @@ -366,6 +435,21 @@ private void MarkGpuDirty() gpuCache = null; } + protected static bool NearlyEqual(float a, float b, float epsilon = DirtyFloatTolerance) + { + return Math.Abs(a - b) <= epsilon; + } + + protected static bool ColorsClose(Col a, Col b, float epsilon = DirtyFloatTolerance) + { + if (a == null || b == null) return a == b; + + return NearlyEqual(a.r, b.r, epsilon) + && NearlyEqual(a.g, b.g, epsilon) + && NearlyEqual(a.b, b.b, epsilon) + && NearlyEqual(a.a, b.a, epsilon); + } + public void DrawCall(SKCanvas canvas) { if (!isEnabled) return; @@ -402,7 +486,7 @@ protected virtual void DrawSelfContents(SKCanvas canvas) var rect = SKRect.Create(0, 0, Size.X, Size.Y); var roundRect = new SKRoundRect(rect, roundRadius); - var paint = GetPaint(); + using var paint = GetPaint(); canvas.DrawRoundRect(roundRect, paint); } @@ -473,7 +557,7 @@ public virtual void Draw(SKCanvas canvas) // Draw the cached image at the object's position canvas.Save(); canvas.Translate(Position.X, Position.Y); - var paint = new SKPaint { FilterQuality = SKFilterQuality.None }; + using var paint = new SKPaint { FilterQuality = SKFilterQuality.None }; canvas.DrawImage(gpuCache, 0, 0, paint); canvas.Restore(); @@ -573,6 +657,9 @@ public virtual void OnGlobalMouseUp() { } public void SilentSetActive(bool isEnabled) { + if (this.isEnabled == isEnabled && lastSetActiveCall == isEnabled) return; + lastSetActiveCall = isEnabled; + OnActiveChanged(isEnabled); this.isEnabled = isEnabled; } @@ -767,4 +854,4 @@ public enum UIAlignment BottomRight, None } -} \ No newline at end of file +} diff --git a/DynamicWin/UI/Widgets/Big/WeatherWidget.cs b/DynamicWin/UI/Widgets/Big/WeatherWidget.cs index 05b995b..3e758d9 100644 --- a/DynamicWin/UI/Widgets/Big/WeatherWidget.cs +++ b/DynamicWin/UI/Widgets/Big/WeatherWidget.cs @@ -276,6 +276,7 @@ public WeatherWidget(UIObject? parent, Vec2 position, UIAlignment alignment = UI } WeatherData lastWeatherData; + string lastTemperatureText = string.Empty; // Logic to handle weather display updates void OnWeatherDataReceived(WeatherData weatherData) { @@ -386,7 +387,12 @@ public override void Update(float deltaTime) { base.Update(deltaTime); - _TemperatureText.SetText(RegisterWeatherWidgetSettings.saveData.useCelsius ? lastWeatherData.celsius : lastWeatherData.fahrenheit); + string nextTemperature = RegisterWeatherWidgetSettings.saveData.useCelsius ? lastWeatherData.celsius : lastWeatherData.fahrenheit; + if (nextTemperature != lastTemperatureText) + { + lastTemperatureText = nextTemperature; + _TemperatureText.SetText(nextTemperature); + } } // Override logic for widget aesthetics diff --git a/DynamicWin/UI/Widgets/Small/BatteryWidget.cs b/DynamicWin/UI/Widgets/Small/BatteryWidget.cs index 40ae2f1..9eeeb01 100644 --- a/DynamicWin/UI/Widgets/Small/BatteryWidget.cs +++ b/DynamicWin/UI/Widgets/Small/BatteryWidget.cs @@ -29,6 +29,10 @@ public class BatteryWidget : SmallWidgetBase DWImage batteryCharging; float imageScale = 1.75f; + private DateTime lastPowerStatusPoll = DateTime.MinValue; + private readonly TimeSpan powerStatusPollInterval = TimeSpan.FromSeconds(5); + private PowerStatusChecker.SYSTEM_POWER_STATUS cachedPowerStatus; + private bool hasCachedPowerStatus; public BatteryWidget(UIObject? parent, Vec2 position, UIAlignment alignment = UIAlignment.TopCenter) : base(parent, position, alignment) { @@ -51,7 +55,15 @@ public override void Update(float deltaTime) { base.Update(deltaTime); - var batteryStatus = PowerStatusChecker.GetPowerStatus(); + var now = DateTime.UtcNow; + if (!hasCachedPowerStatus || (now - lastPowerStatusPoll) >= powerStatusPollInterval) + { + cachedPowerStatus = PowerStatusChecker.GetPowerStatus(); + hasCachedPowerStatus = true; + lastPowerStatusPoll = now; + } + + var batteryStatus = cachedPowerStatus; if (batteryStatus.BatteryFlag != ((byte)128)) { diff --git a/DynamicWin/UI/Widgets/Small/MediaThumbnailWidget.cs b/DynamicWin/UI/Widgets/Small/MediaThumbnailWidget.cs index afdd02e..07dbadb 100644 --- a/DynamicWin/UI/Widgets/Small/MediaThumbnailWidget.cs +++ b/DynamicWin/UI/Widgets/Small/MediaThumbnailWidget.cs @@ -1,4 +1,4 @@ -using DynamicWin.Utils; +using DynamicWin.Utils; using DynamicWin.UI.UIElements; using SkiaSharp; using System; @@ -6,6 +6,7 @@ using System.Threading; using System.Diagnostics; using DynamicWin.Resources; +using DynamicWin.Main; namespace DynamicWin.UI.Widgets.Small { @@ -50,8 +51,18 @@ public class MediaThumbnailWidget : SmallWidgetBase private ulong? currentBitmapFingerprint = null; private ulong? pendingBitmapFingerprint = null; + // Cancellation token for pending decode tasks + private CancellationTokenSource? decodeCts = null; + private CancellationTokenSource? thumbnailRetryCts = null; + private int decodeVersion = 0; + public MediaThumbnailWidget(UIObject? parent, Vec2 position, UIAlignment alignment = UIAlignment.TopCenter) : base(parent, position, alignment) { + UseGpuCaching = false; + + // Ensure shared setting loaded + try { RegisterSmallVisualiserWidgetSettings.SharedMediaSettings.Load(); } catch { } + MediaThumbnailService.Instance.ThumbnailChanged += OnThumbnailChanged; // Try to initialise from service canonical bitmap (fast path) @@ -70,7 +81,23 @@ public MediaThumbnailWidget(UIObject? parent, Vec2 position, UIAlignment alignme thumbnailBitmap = bmp; try { currentBitmapFingerprint = BitmapUtils.GetBitmapFingerprint(bmp); } catch { currentBitmapFingerprint = null; } hasMedia = true; - collapseProgress = 1f; + + // Respect shared hide-when-idle setting: if enabled and media has been paused for longer than 30 seconds, start collapsed + bool hideWhenIdle = false; + try { hideWhenIdle = RegisterSmallVisualiserWidgetSettings.SharedMediaSettings.HideMediaWhenIdle; } catch { hideWhenIdle = false; } + if (hideWhenIdle) + { + try + { + bool pausedLong = MediaThumbnailService.Instance.IsPausedLongerThan(TimeSpan.FromSeconds(30)); + collapseProgress = pausedLong ? 0f : 1f; + } + catch { collapseProgress = 1f; } + } + else + { + collapseProgress = 1f; + } } } catch { } @@ -85,18 +112,77 @@ public MediaThumbnailWidget(UIObject? parent, Vec2 position, UIAlignment alignme private void OnThumbnailChanged(object? sender, MediaChangedEventArgs e) { + int changeVersion = Interlocked.Increment(ref decodeVersion); + + // Cancel any pending decode tasks when media changes (but don't dispose yet - task might still reference it) + try + { + decodeCts?.Cancel(); + thumbnailRetryCts?.Cancel(); + } + catch { } + // Adopt metadata immediately and ensure widget expanded when media exists lock (mediaLock) { + // If shared setting requests hiding media when idle, only expand when playback active + bool hideWhenIdle = false; + try { hideWhenIdle = RegisterSmallVisualiserWidgetSettings.SharedMediaSettings.HideMediaWhenIdle; } catch { hideWhenIdle = false; } + if (e.Media != null) { + // Check if this is a transition from no-media to has-media + bool wasNoMedia = !hasMedia || collapseProgress <= 0.001f; hasMedia = true; - BeginInvokeUI(() => StartCollapseOrExpand(true)); + + // If hideWhenIdle is enabled, determine expand state based on playback status and pause duration + // BUT: when NEW media arrives (transition from null to non-null), always show it immediately + if (hideWhenIdle) + { + try + { + var status = DynamicWin.Utils.MediaThumbnailService.Instance?.LastPlaybackStatus; + bool playing = !status.HasValue || status.Value == Windows.Media.Control.GlobalSystemMediaTransportControlsSessionPlaybackStatus.Playing; + + // When playing, always show + // When transitioning from no-media to media, always show (even if paused) + // When paused and already had media, only hide if paused 30+ seconds + bool shouldExpand = playing || wasNoMedia; + + if (!playing && !wasNoMedia) + { + // Was already showing media and now paused - check if paused long enough to hide + bool pausedLong = MediaThumbnailService.Instance.IsPausedLongerThan(TimeSpan.FromSeconds(30)); + shouldExpand = !pausedLong; + } + + BeginInvokeUI(() => StartCollapseOrExpand(shouldExpand)); + } + catch + { + BeginInvokeUI(() => StartCollapseOrExpand(true)); + } + } + else + { + // Always expand when media exists and hideWhenIdle is off + BeginInvokeUI(() => StartCollapseOrExpand(true)); + } } else { - // No media -> collapse after a short delay to avoid flicker + // No media -> collapse and immediately clear bitmaps to prevent stale display hasMedia = false; + animator.ForceFinish(); + + // Immediately dispose bitmaps when media becomes null (don't wait for animation) + if (thumbnailBitmap != null) { try { thumbnailBitmap.Dispose(); } catch { } thumbnailBitmap = null; currentBitmapFingerprint = null; } + if (pendingBitmap != null) { try { pendingBitmap.Dispose(); } catch { } pendingBitmap = null; pendingBitmapFingerprint = null; } + if (previousBitmap != null) { try { previousBitmap.Dispose(); } catch { } previousBitmap = null; } + pendingMedia = null; + currentMediaKey = null; + pendingMediaKey = null; + BeginInvokeUI(() => StartCollapseOrExpand(false)); } } @@ -105,12 +191,14 @@ private void OnThumbnailChanged(object? sender, MediaChangedEventArgs e) byte[]? bytes = e.ThumbnailBytes; Media? media = e.Media; - if (bytes == null || bytes.Length == 0) + if (media != null && (bytes == null || bytes.Length == 0)) { // Try to read cached bytes from service (fast, non-blocking) try { bytes = MediaThumbnailService.Instance.GetCurrentThumbnailBytes(); } catch { bytes = null; } } + if (media == null) return; + if (bytes != null && bytes.Length > 0) { // Throttle rapid decode attempts @@ -118,12 +206,23 @@ private void OnThumbnailChanged(object? sender, MediaChangedEventArgs e) if ((now - lastDecodeTime) < minDecodeInterval) { // Schedule a short delayed decode to coalesce rapid events - Task.Delay((int)minDecodeInterval.TotalMilliseconds).ContinueWith(_ => DecodeAndQueue(bytes, media)); + decodeCts = new CancellationTokenSource(); + var cts = decodeCts; + Task.Delay((int)minDecodeInterval.TotalMilliseconds, cts.Token).ContinueWith(_ => + { + if (!cts.Token.IsCancellationRequested) + { + DecodeAndQueue(bytes, media, cts.Token, changeVersion); + } + // Let the CTS be replaced by next notification, not here + }, TaskScheduler.Default); } else { lastDecodeTime = now; - _ = Task.Run(() => DecodeAndQueue(bytes, media)); + decodeCts = new CancellationTokenSource(); + var cts = decodeCts; + _ = Task.Run(() => DecodeAndQueue(bytes, media, cts.Token, changeVersion), cts.Token); } } else @@ -136,15 +235,67 @@ private void OnThumbnailChanged(object? sender, MediaChangedEventArgs e) { currentMediaKey = $"{media.Title ?? string.Empty}|{media.Artist ?? string.Empty}|0"; } + + StartThumbnailRetry(media, changeVersion); } } } - private void DecodeAndQueue(byte[] bytes, Media? media) + private void StartThumbnailRetry(Media media, int changeVersion) { + thumbnailRetryCts = new CancellationTokenSource(); + var cts = thumbnailRetryCts; + + _ = Task.Run(async () => + { + try + { + for (int attempt = 0; attempt < 16; attempt++) + { + if (cts.Token.IsCancellationRequested || changeVersion != Volatile.Read(ref decodeVersion)) return; + + byte[]? bytes = null; + try { bytes = MediaThumbnailService.Instance.GetCurrentThumbnailBytes(); } catch { bytes = null; } + + if (bytes == null || bytes.Length == 0) + { + try { bytes = await MediaInfo.FetchCurrentThumbnailBytesAsync(forceRefresh: true).ConfigureAwait(false); } + catch { bytes = null; } + } + + if (cts.Token.IsCancellationRequested || changeVersion != Volatile.Read(ref decodeVersion)) return; + + if (bytes != null && bytes.Length > 0) + { + DecodeAndQueue((byte[])bytes.Clone(), media, cts.Token, changeVersion); + return; + } + + try { await Task.Delay(250, cts.Token).ConfigureAwait(false); } + catch { return; } + } + } + catch { } + }, cts.Token); + } + + private void DecodeAndQueue(byte[] bytes, Media? media, CancellationToken ct, int changeVersion) + { + // Check cancellation early + try + { + if (ct.IsCancellationRequested || changeVersion != Volatile.Read(ref decodeVersion)) return; + } + catch + { + // CancellationToken might be disposed, just return + return; + } + SKBitmap? bmp = null; try { + if (ct.IsCancellationRequested || changeVersion != Volatile.Read(ref decodeVersion)) return; using var ms = new SKMemoryStream(bytes); bmp = SKBitmap.Decode(ms); } @@ -156,11 +307,41 @@ private void DecodeAndQueue(byte[] bytes, Media? media) if (bmp == null) return; + // Check again before fingerprinting + try + { + if (ct.IsCancellationRequested || changeVersion != Volatile.Read(ref decodeVersion)) + { + bmp?.Dispose(); + return; + } + } + catch + { + bmp?.Dispose(); + return; + } + ulong? fp = null; try { fp = BitmapUtils.GetBitmapFingerprint(bmp); } catch { fp = null; } lock (mediaLock) { + // Final check before queuing + try + { + if (ct.IsCancellationRequested || changeVersion != Volatile.Read(ref decodeVersion)) + { + bmp?.Dispose(); + return; + } + } + catch + { + bmp?.Dispose(); + return; + } + // If image visually identical to currently displayed, adopt metadata only if (fp.HasValue && currentBitmapFingerprint.HasValue && fp.Value == currentBitmapFingerprint.Value) { @@ -172,6 +353,16 @@ private void DecodeAndQueue(byte[] bytes, Media? media) return; } + if (thumbnailBitmap == null) + { + thumbnailBitmap = bmp; + currentBitmapFingerprint = fp; + currentMediaKey = (media == null) ? string.Empty : $"{media.Title ?? string.Empty}|{media.Artist ?? string.Empty}|{bytes.Length}"; + pendingMedia = null; + pendingMediaKey = null; + return; + } + // If we already have a pending bitmap, replace it if (pendingBitmap != null) { @@ -250,7 +441,7 @@ public override void Update(float deltaTime) bool isPaused = false; try { - var status = DynamicWin.Utils.MediaThumbnailService.Instance?.LastPlaybackStatus; + var status = MediaThumbnailService.Instance?.LastPlaybackStatus; if (status.HasValue) { isPaused = status.Value != Windows.Media.Control.GlobalSystemMediaTransportControlsSessionPlaybackStatus.Playing; @@ -267,6 +458,8 @@ public override void Update(float deltaTime) { lock (mediaLock) { + if (pendingBitmap == null) return; + if (thumbnailBitmap != null) { try { thumbnailBitmap.Dispose(); } catch { } @@ -296,6 +489,27 @@ public override void Update(float deltaTime) }); } + public override bool WantsRealtimeUpdate + { + get + { + bool isPaused = false; + try + { + var status = MediaThumbnailService.Instance?.LastPlaybackStatus; + if (status.HasValue) + isPaused = status.Value != Windows.Media.Control.GlobalSystemMediaTransportControlsSessionPlaybackStatus.Playing; + } + catch { } + + float target = isPaused ? 0f : 1f; + return animator.State != MediaAnimator.AnimState.Idle || + Math.Abs(thumbnailAnim - target) > 0.01f; + } + } + + public override bool WantsContinuousUpdate => WantsRealtimeUpdate; + public override void Draw(SKCanvas canvas) { if (collapseProgress <= 0f) return; @@ -311,14 +525,14 @@ public override void Draw(SKCanvas canvas) SKBitmap? bmp; lock (mediaLock) { bmp = thumbnailBitmap; } - var path = BuildSuperellipsePath(thumbRect, 7f, 1f); + using var path = BuildSuperellipsePath(thumbRect, 7f, 1f); try { float flipScale = animator.GetFlipScale(); bool doFlip = animator.IsFlipping; - float thumbScale = 0.6f + 0.4f * thumbnailAnim; + float thumbScale = 0.8f + 0.2f * thumbnailAnim; float dimAlpha = (1f - thumbnailAnim) * 120f; float centerX = thumbRect.MidX; float centerY = thumbRect.MidY; @@ -331,11 +545,11 @@ public override void Draw(SKCanvas canvas) canvas.Translate(cx, cy); canvas.Scale(flipScale * thumbScale, thumbScale); var localRect = SKRect.Create(-thumbRect.Width / 2f, -thumbRect.Height / 2f, thumbRect.Width, thumbRect.Height); - var localPath = BuildSuperellipsePath(localRect, 7f, 1f); + using var localPath = BuildSuperellipsePath(localRect, 7f, 1f); canvas.Save(); - canvas.ClipPath(localPath, antialias: true); - var paint = GetPaint(); - paint.IsAntialias = true; + canvas.ClipPath(localPath, antialias: Settings.AntiAliasing); + using var paint = GetPaint(); + paint.IsAntialias = Settings.AntiAliasing; paint.ImageFilter = animator.BlurAmount > 0f ? SKImageFilter.CreateBlur(animator.BlurAmount, animator.BlurAmount) : null; if (bmp != null) { @@ -361,9 +575,9 @@ public override void Draw(SKCanvas canvas) canvas.Translate(centerX, centerY); canvas.Scale(thumbScale, thumbScale); canvas.Translate(-centerX, -centerY); - canvas.ClipPath(path, antialias: true); - var paint = GetPaint(); - paint.IsAntialias = true; + canvas.ClipPath(path, antialias: Settings.AntiAliasing); + using var paint = GetPaint(); + paint.IsAntialias = Settings.AntiAliasing; paint.ImageFilter = animator.BlurAmount > 0f ? SKImageFilter.CreateBlur(animator.BlurAmount, animator.BlurAmount) : null; if (bmp != null) { @@ -393,6 +607,9 @@ public override void OnDestroy() { base.OnDestroy(); try { MediaThumbnailService.Instance.ThumbnailChanged -= OnThumbnailChanged; } catch { } + Interlocked.Increment(ref decodeVersion); + try { decodeCts?.Cancel(); } catch { } + try { thumbnailRetryCts?.Cancel(); } catch { } // Ensure animation is finished and thumbnail is visible ForceFinishAnimation(); // Dispose owned bitmaps diff --git a/DynamicWin/UI/Widgets/Small/SmallVisualiserWidget.cs b/DynamicWin/UI/Widgets/Small/SmallVisualiserWidget.cs index 4005543..3f197c8 100644 --- a/DynamicWin/UI/Widgets/Small/SmallVisualiserWidget.cs +++ b/DynamicWin/UI/Widgets/Small/SmallVisualiserWidget.cs @@ -39,6 +39,34 @@ class RegisterSmallVisualiserWidgetSettings : IRegisterableSetting public static SmallVisualiserSave saveData; + // Shared setting between visualiser and media thumbnail widgets + public static class SharedMediaSettings + { + public const string SettingKey = "Setting.HideMediaWhenIdle"; + public static bool HideMediaWhenIdle = false; + + public static void Load() + { + try + { + if (SaveManager.Contains(SettingKey)) + { + HideMediaWhenIdle = (bool)SaveManager.Get(SettingKey); + } + else + { + HideMediaWhenIdle = false; + } + } + catch { HideMediaWhenIdle = false; } + } + + public static void Save() + { + try { SaveManager.Add(SettingKey, HideMediaWhenIdle); } catch { } + } + } + public struct SmallVisualiserSave { public bool displayDotWhenIdle; @@ -47,7 +75,7 @@ public struct SmallVisualiserSave } /// - /// Loads the visualizer settings from persistent storage or initializes them with default values if no saved + /// Loads the visualiser settings from persistent storage or initialises them with default values if no saved /// settings are found. /// /// This method attempts to retrieve previously saved settings using the current setting @@ -68,6 +96,9 @@ public void LoadSettings() useThumbnailBackground = true }; } + + // Load shared setting + SharedMediaSettings.Load(); } /// @@ -79,6 +110,9 @@ public void LoadSettings() public void SaveSettings() { SaveManager.Add(SettingID, JsonConvert.SerializeObject(saveData)); + + // Persist shared setting + SharedMediaSettings.Save(); } /// @@ -96,7 +130,9 @@ public List SettingsObjects() var displayDotWhenIdle = new DWCheckbox(null, "Display visualiser dots when idle", new Vec2(25, 0), new Vec2(25, 25), null, UIAlignment.TopLeft); var enableColourTransition = new DWCheckbox(null, "Enable visualiser colour transitioning", new Vec2(25, 0), new Vec2(25, 25), null, UIAlignment.TopLeft); var useThumbnailBackground = new DWCheckbox(null, "Use media thumbnail as background", new Vec2(25, 0), new Vec2(25, 25), null, UIAlignment.TopLeft); - var thumbnailDisclaimer = new DWText(null, "By enabling this option, colour transitioning will be bypassed.", new Vec2(25, 0), UIAlignment.TopLeft); + var thumbnailDisclaimer = new DWText(null, "Colour transition and media thumbnail options are mutually exclusive.", new Vec2(25, 0), UIAlignment.TopLeft); + var hideMediaWhenIdle = new DWCheckbox(null, "Hide media thumbnail when idle (shared)", new Vec2(25, 0), new Vec2(25, 25), null, UIAlignment.TopLeft); + var hideMediaDisclaimer = new DWText(null, "Hides media thumbnail and visualiser after 30 seconds when paused.", new Vec2(25, 0), UIAlignment.TopLeft); displayDotWhenIdle.clickCallback += () => { @@ -106,26 +142,51 @@ public List SettingsObjects() enableColourTransition.clickCallback += () => { saveData.enableColourTransition = enableColourTransition.IsChecked; + + if (enableColourTransition.IsChecked) + { + // If enabling colour transition, disable thumbnail background + saveData.useThumbnailBackground = false; + useThumbnailBackground.IsChecked = false; + } }; useThumbnailBackground.clickCallback += () => { saveData.useThumbnailBackground = useThumbnailBackground.IsChecked; + + if (useThumbnailBackground.IsChecked) + { + // If enabling thumbnail background, disable colour transition + saveData.enableColourTransition = false; + enableColourTransition.IsChecked = false; + } + }; + + hideMediaWhenIdle.clickCallback += () => + { + SharedMediaSettings.HideMediaWhenIdle = hideMediaWhenIdle.IsChecked; + // Save immediately so other widget instances can read it + SharedMediaSettings.Save(); }; displayDotWhenIdle.IsChecked = saveData.displayDotWhenIdle; enableColourTransition.IsChecked = saveData.enableColourTransition; useThumbnailBackground.IsChecked = saveData.useThumbnailBackground; + hideMediaWhenIdle.IsChecked = SharedMediaSettings.HideMediaWhenIdle; displayDotWhenIdle.Anchor.X = 0; enableColourTransition.Anchor.X = 0; thumbnailDisclaimer.Anchor.X = 0; useThumbnailBackground.Anchor.X = 0; + hideMediaWhenIdle.Anchor.X = 0; objects.Add(displayDotWhenIdle); - objects.Add(enableColourTransition); objects.Add(thumbnailDisclaimer); + objects.Add(enableColourTransition); objects.Add(useThumbnailBackground); + objects.Add(hideMediaDisclaimer); + objects.Add(hideMediaWhenIdle); return objects; } @@ -156,6 +217,7 @@ public SmallVisualiserWidget(UIObject? parent, Vec2 position, UIAlignment alignm audioVisualiser.UseThumbnailBackground = RegisterSmallVisualiserWidgetSettings.saveData.useThumbnailBackground; audioVisualiser.EnableDotWhenLow = RegisterSmallVisualiserWidgetSettings.saveData.displayDotWhenIdle; audioVisualiser.BlurAmount = 0.3f; + audioVisualiser.BarSpacing = 1.8f; AddLocalObject(audioVisualiser); @@ -181,15 +243,41 @@ private async void OnServiceThumbnailChanged(object? sender, MediaChangedEventAr return; } + // Check if this is a transition from no-media to has-media + bool wasNoMedia = !hasMedia || collapseProgress <= 0.001f; + // On metadata present, fetch timeline once (service will have invalidated timeline on metadata change) try { var tl = await MediaInfo.FetchCurrentTimelineAsync().ConfigureAwait(false); - bool shouldExpand = tl != null && tl.PlaybackStatus != GlobalSystemMediaTransportControlsSessionPlaybackStatus.Closed; hasMedia = true; - targetExpanded = shouldExpand; + // Evaluate shared setting: if HideMediaWhenIdle is enabled and media has been paused for >= 30 seconds, collapse + bool hideWhenIdle = RegisterSmallVisualiserWidgetSettings.SharedMediaSettings.HideMediaWhenIdle; + bool shouldExpand; + + if (hideWhenIdle) + { + // When playing, always expand + // When transitioning from no-media to media, always expand (even if paused) + // When paused and already had media, only hide if paused 30+ seconds + bool isPlaying = tl == null || tl.PlaybackStatus == GlobalSystemMediaTransportControlsSessionPlaybackStatus.Playing; + shouldExpand = isPlaying || wasNoMedia; + + if (!isPlaying && !wasNoMedia) + { + // Was already showing media and now paused - check if paused long enough to hide + bool pausedLong = MediaThumbnailService.Instance.IsPausedLongerThan(TimeSpan.FromSeconds(30)); + shouldExpand = !pausedLong; + } + } + else + { + shouldExpand = true; + } + + targetExpanded = shouldExpand; BeginInvokeUI(() => StartCollapseOrExpand(shouldExpand)); } catch @@ -219,6 +307,8 @@ private void StartCollapseOrExpand(bool expand) collapseAnim = new Animator(300, 1); bool expanding = expand; + if (expanding) + audioVisualiser.SetCapturing(IsEnabled); collapseAnim.onAnimationUpdate += (t) => { @@ -234,6 +324,7 @@ private void StartCollapseOrExpand(bool expand) { collapseProgress = expanding ? 1f : 0f; audioVisualiser.SilentSetActive(expanding); + audioVisualiser.SetCapturing(expanding && IsEnabled); try { DestroyLocalObject(collapseAnim); } catch { } collapseAnim = null; @@ -243,6 +334,12 @@ private void StartCollapseOrExpand(bool expand) collapseAnim.Start(); } + protected override void OnActiveChanged(bool isEnabled) + { + base.OnActiveChanged(isEnabled); + audioVisualiser.SetCapturing(isEnabled && collapseProgress > 0.001f); + } + protected override float GetWidgetWidth() { float full = base.GetWidgetWidth() - 10; diff --git a/DynamicWin/UI/Widgets/Small/TimeWidget.cs b/DynamicWin/UI/Widgets/Small/TimeWidget.cs index f345839..f6f690d 100644 --- a/DynamicWin/UI/Widgets/Small/TimeWidget.cs +++ b/DynamicWin/UI/Widgets/Small/TimeWidget.cs @@ -79,20 +79,33 @@ public List SettingsObjects() public class TimeWidget : SmallWidgetBase { DWText timeText; + private string lastRenderedTime = string.Empty; + private DateTime nextTimeRefresh = DateTime.MinValue; public TimeWidget(UIObject? parent, Vec2 position, UIAlignment alignment = UIAlignment.TopCenter) : base(parent, position, alignment) { - timeText = new DWText(this, GetTime(), Vec2.zero, UIAlignment.Center); + lastRenderedTime = GetTime(); + timeText = new DWText(this, lastRenderedTime, Vec2.zero, UIAlignment.Center); timeText.TextSize = 14; timeText.Font = Res.SFProRegular; AddLocalObject(timeText); + ScheduleNextRefresh(); } public override void Update(float deltaTime) { base.Update(deltaTime); - timeText.Text = GetTime(); + if (DateTime.Now < nextTimeRefresh) return; + + string current = GetTime(); + if (current != lastRenderedTime) + { + lastRenderedTime = current; + timeText.SilentSetText(current); + } + + ScheduleNextRefresh(); } protected override float GetWidgetWidth() { return RegisterTimeWidgetSettings.saveData.militaryTime ? 35 : 55; } @@ -101,5 +114,13 @@ string GetTime() { return RegisterTimeWidgetSettings.saveData.militaryTime ? DateTime.Now.ToString("HH:mm") : DateTime.Now.ToString("hh:mm tt", new System.Globalization.CultureInfo("en-US")); } + + private void ScheduleNextRefresh() + { + var now = DateTime.Now; + nextTimeRefresh = new DateTime(now.Year, now.Month, now.Day, now.Hour, now.Minute, 0) + .AddMinutes(1) + .AddMilliseconds(50); + } } } diff --git a/DynamicWin/UI/Widgets/Small/UsedDevicesWidget.cs b/DynamicWin/UI/Widgets/Small/UsedDevicesWidget.cs index 40a191a..e041622 100644 --- a/DynamicWin/UI/Widgets/Small/UsedDevicesWidget.cs +++ b/DynamicWin/UI/Widgets/Small/UsedDevicesWidget.cs @@ -157,21 +157,32 @@ protected override float GetWidgetWidth() float sinCycleMicrophone = 0f; float sinSpeed = 2.75f; + private DateTime lastDeviceUsagePoll = DateTime.MinValue; + private readonly TimeSpan deviceUsagePollInterval = TimeSpan.FromMilliseconds(750); + private bool cachedCamActive; + private bool cachedMicActive; public override void Update(float deltaTime) { base.Update(deltaTime); - sinCycleCamera += sinSpeed * deltaTime; - sinCycleMicrophone += sinSpeed * deltaTime; + if (cachedCamActive) + sinCycleCamera += sinSpeed * deltaTime; + if (cachedMicActive || isMicrophoneIndicatorShowing) + sinCycleMicrophone += sinSpeed * deltaTime; - bool isCamActive = DeviceUsageChecker.IsWebcamInUse(); - bool isMicActive = DeviceUsageChecker.IsMicrophoneInUse(); + var now = DateTime.UtcNow; + if ((now - lastDeviceUsagePoll) >= deviceUsagePollInterval) + { + lastDeviceUsagePoll = now; + cachedCamActive = DeviceUsageChecker.IsWebcamInUse(); + cachedMicActive = DeviceUsageChecker.IsMicrophoneInUse(); + } - camDotSizeCurrent = Mathf.Lerp(camDotSizeCurrent, isCamActive ? camDotSize : 0f, 5f * deltaTime); - micDotSizeCurrent = Mathf.Lerp(micDotSizeCurrent, isMicActive ? micDotSize : 0f, 5f * deltaTime); + camDotSizeCurrent = Mathf.Lerp(camDotSizeCurrent, cachedCamActive ? camDotSize : 0f, 5f * deltaTime); + micDotSizeCurrent = Mathf.Lerp(micDotSizeCurrent, cachedMicActive ? micDotSize : 0f, 5f * deltaTime); - if(isCamActive && isMicActive) + if(cachedCamActive && cachedMicActive) { camDotPositionX = Mathf.Lerp(camDotPositionX, seperation, 5f * deltaTime); micDotPositionX = Mathf.Lerp(micDotPositionX, -seperation, 5f * deltaTime); @@ -182,11 +193,20 @@ public override void Update(float deltaTime) micDotPositionX = Mathf.Lerp(micDotPositionX, 0, 5f * deltaTime); } - isMicrophoneIndicatorShowing = LoudnessMeter.GetMicrophoneLoudness() > RegisterUsedDevicesOptions.saveData.indicatorThreshold; + isMicrophoneIndicatorShowing = + RegisterUsedDevicesOptions.saveData.enableIndicator && + LoudnessMeter.GetMicrophoneLoudness() > RegisterUsedDevicesOptions.saveData.indicatorThreshold; } bool isMicrophoneIndicatorShowing = false; + public override bool WantsContinuousUpdate => + cachedCamActive || + cachedMicActive || + isMicrophoneIndicatorShowing || + camDotSizeCurrent > 0.05f || + micDotSizeCurrent > 0.05f; + public override void DrawWidget(SKCanvas canvas) { var paint = GetPaint(); diff --git a/DynamicWin/UI/Widgets/WidgetBase.cs b/DynamicWin/UI/Widgets/WidgetBase.cs index 128739f..b5567e1 100644 --- a/DynamicWin/UI/Widgets/WidgetBase.cs +++ b/DynamicWin/UI/Widgets/WidgetBase.cs @@ -72,7 +72,7 @@ public override void Draw(SKCanvas canvas) if (hoverProgress > 0.025f) { - var paint = GetPaint(); + using var paint = GetPaint(); paint.ImageFilter = SKImageFilter.CreateDropShadowOnly( 0, 0, hoverProgress * 10, hoverProgress * 10, @@ -86,7 +86,7 @@ public override void Draw(SKCanvas canvas) canvas.Scale(1 + hoverProgress / 60, 1 + hoverProgress / 60, p.X, p.Y); // Build squircle path for hover shadow - var shadowPath = BuildSuperellipsePath(GetRawRect(), radius: roundRadius, t: 1.0f); + using var shadowPath = BuildSuperellipsePath(GetRawRect(), radius: roundRadius, t: 1.0f); // Clip outside the widget rect and draw shadow int clipSave = canvas.Save(); @@ -102,7 +102,7 @@ public override void Draw(SKCanvas canvas) if (isEditMode) { - var paint = GetPaint(); + using var paint = GetPaint(); paint.IsStroke = true; paint.StrokeCap = SKStrokeCap.Round; @@ -113,7 +113,7 @@ public override void Draw(SKCanvas canvas) var brect = SKRect.Create(Position.X - expand / 2, Position.Y - expand / 2, Size.X + expand, Size.Y + expand); // Squircle path for edit mode border - var borderPath = BuildSuperellipsePath(brect, radius: roundRadius, t: 1.0f); + using var borderPath = BuildSuperellipsePath(brect, radius: roundRadius, t: 1.0f); int noClip = canvas.Save(); paint.Color = SKColors.DimGray; @@ -183,23 +183,9 @@ protected override void OnActiveChanged(bool isEnabled) /// IMPORTANT: Do not touch UI objects directly from this method. Marshal to UI thread via BeginInvokeUI(...) OR update thread-safe fields and read them on UI thread /// Default implementation is a simple no-op loop /// - protected virtual async Task RunBackgroundAsync(CancellationToken token) + protected virtual Task RunBackgroundAsync(CancellationToken token) { - // Default: nothing heavy, but keep an awaitable loop so derived classes can override without re-implementing the loop - try - { - // Use a short non-cancelable delay to avoid throwing TaskCanceledException when the token is cancelled - // Checking the token between delays keeps shutdown responsive without generating exceptions - while (!token.IsCancellationRequested) - { - await Task.Delay(200).ConfigureAwait(false); - } - } - catch (Exception ex) when (!(ex is OperationCanceledException)) - { - // Log unexpected exceptions but avoid noisy cancellation exceptions - System.Diagnostics.Debug.WriteLine("[WIDGET BASE] RunBackgroundAsync exception: " + ex); - } + return Task.CompletedTask; } /// diff --git a/DynamicWin/Utils/Animator.cs b/DynamicWin/Utils/Animator.cs index 9498659..4762ab3 100644 --- a/DynamicWin/Utils/Animator.cs +++ b/DynamicWin/Utils/Animator.cs @@ -29,6 +29,8 @@ public override void Draw(SKCanvas canvas) } + public override bool WantsRealtimeUpdate => isRunning; + public void Interrupt() { onAnimationInterrupt?.Invoke(); @@ -39,6 +41,7 @@ public void Start() { isRunning = true; elapsed = 0; + try { MainForm.Instance?.RequestRenderBurst(animationDuration + 50); } catch { } } float elapsed = 0; diff --git a/DynamicWin/Utils/AppBarHelper.cs b/DynamicWin/Utils/AppBarHelper.cs new file mode 100644 index 0000000..3644e28 --- /dev/null +++ b/DynamicWin/Utils/AppBarHelper.cs @@ -0,0 +1,141 @@ +using System.Runtime.InteropServices; +using System.Windows; +using System.Windows.Interop; + +namespace DynamicWin.Utils +{ + public static class AppBarHelper + { + [StructLayout(LayoutKind.Sequential)] + private struct RECT + { + public int left; + public int top; + public int right; + public int bottom; + } + + [StructLayout(LayoutKind.Sequential)] + private struct APPBARDATA + { + public int cbSize; + public IntPtr hWnd; + public int uCallbackMessage; + public int uEdge; + public RECT rc; + public IntPtr lParam; + } + + private enum ABMsg : int + { + ABM_NEW = 0, + ABM_REMOVE = 1, + ABM_QUERYPOS = 2, + ABM_SETPOS = 3, + ABM_GETSTATE = 4, + ABM_GETTASKBARPOS = 5, + ABM_ACTIVATE = 6, + ABM_GETAUTOHIDEBAR = 7, + ABM_SETAUTOHIDEBAR = 8, + ABM_WINDOWPOSCHANGED = 9, + ABM_SETSTATE = 10 + } + + private enum ABEdge : int + { + ABE_LEFT = 0, + ABE_TOP = 1, + ABE_RIGHT = 2, + ABE_BOTTOM = 3 + } + + [DllImport("shell32.dll", CallingConvention = CallingConvention.StdCall)] + private static extern IntPtr SHAppBarMessage(int dwMessage, ref APPBARDATA pData); + + [DllImport("user32.dll", SetLastError = true)] + private static extern IntPtr FindWindow(string lpClassName, string lpWindowName); + + private static bool _isRegistered = false; + + private static IntPtr _lastRegisteredHWnd = IntPtr.Zero; + + public static void RegisterAppBar(Window window, int height) + { + if (_isRegistered) UnregisterAppBar(window); + + var helper = new WindowInteropHelper(window); + IntPtr hWnd = helper.Handle; + _lastRegisteredHWnd = hWnd; + + var screen = System.Windows.Forms.Screen.FromHandle(hWnd); + + APPBARDATA abd = new APPBARDATA(); + abd.cbSize = Marshal.SizeOf(typeof(APPBARDATA)); + abd.hWnd = hWnd; + abd.uCallbackMessage = 0x8000 + 101; + + SHAppBarMessage((int)ABMsg.ABM_NEW, ref abd); + _isRegistered = true; + + abd.rc.top = screen.Bounds.Top; + abd.rc.left = screen.Bounds.Left; + abd.rc.right = screen.Bounds.Right; + abd.rc.bottom = screen.Bounds.Top + height; + abd.uEdge = (int)ABEdge.ABE_TOP; + + SHAppBarMessage((int)ABMsg.ABM_QUERYPOS, ref abd); + SHAppBarMessage((int)ABMsg.ABM_SETPOS, ref abd); + } + + public static void UnregisterAppBar(Window window) + { + var helper = new WindowInteropHelper(window); + UnregisterAppBar(helper.Handle); + } + + public static void UnregisterAppBar(IntPtr hWnd) + { + if (hWnd == IntPtr.Zero) return; + + APPBARDATA abd = new APPBARDATA(); + abd.cbSize = Marshal.SizeOf(typeof(APPBARDATA)); + abd.hWnd = hWnd; + + SHAppBarMessage((int)ABMsg.ABM_REMOVE, ref abd); + _isRegistered = false; + + if (hWnd == _lastRegisteredHWnd) + _lastRegisteredHWnd = IntPtr.Zero; + } + + public static void ForceUnregisterLast() + { + if (_lastRegisteredHWnd != IntPtr.Zero) + { + UnregisterAppBar(_lastRegisteredHWnd); + } + } + + public static void SetAppBarPos(Window window, int height) + { + if (!_isRegistered) return; + + var helper = new WindowInteropHelper(window); + APPBARDATA abd = new APPBARDATA(); + abd.cbSize = Marshal.SizeOf(typeof(APPBARDATA)); + abd.hWnd = helper.Handle; + abd.uEdge = (int)ABEdge.ABE_TOP; + + var screen = System.Windows.Forms.Screen.FromHandle(helper.Handle); + + abd.rc.left = screen.Bounds.Left; + abd.rc.right = screen.Bounds.Right; + abd.rc.top = screen.Bounds.Top; + abd.rc.bottom = screen.Bounds.Top + height; + + SHAppBarMessage((int)ABMsg.ABM_QUERYPOS, ref abd); + + SHAppBarMessage((int)ABMsg.ABM_SETPOS, ref abd); + } + } +} diff --git a/DynamicWin/Utils/AppBarManager.cs b/DynamicWin/Utils/AppBarManager.cs new file mode 100644 index 0000000..1b5ee1c --- /dev/null +++ b/DynamicWin/Utils/AppBarManager.cs @@ -0,0 +1,125 @@ +using System.Runtime.InteropServices; +using System.Windows; +using System.Windows.Media; +using System.Windows.Interop; + +namespace DynamicWin.Utils +{ + public static class AppBarManager + { + [StructLayout(LayoutKind.Sequential)] + private struct APPBARDATA + { + public int cbSize; + public IntPtr hWnd; + public uint uCallbackMessage; + public uint uEdge; + public RECT rc; + public int lParam; + } + + [StructLayout(LayoutKind.Sequential)] + private struct RECT + { + public int Left, Top, Right, Bottom; + } + + private const uint ABM_NEW = 0x00000000; + private const uint ABM_REMOVE = 0x00000001; + private const uint ABM_QUERYPOS = 0x00000002; + private const uint ABM_SETPOS = 0x00000003; + private const uint ABE_TOP = 1; + + [DllImport("shell32.dll", SetLastError = true)] + private static extern IntPtr SHAppBarMessage(uint dwMessage, ref APPBARDATA pData); + + private static bool _isRegistered = false; + private static IntPtr _registeredHwnd = IntPtr.Zero; + + public static bool IsRegistered => _isRegistered; + + public static void Register(Window window, int screenIndex, int reserveHeight = 38) + { + if (_isRegistered) return; + + try + { + var hwnd = new WindowInteropHelper(window).Handle; + if (hwnd == IntPtr.Zero) return; + + var screens = System.Windows.Forms.Screen.AllScreens; + int idx = Math.Clamp(screenIndex, 0, screens.Length - 1); + var bounds = screens[idx].Bounds; + + double dpiScale = GetDpiScale(window); + int physicalHeight = (int)Math.Round(reserveHeight * dpiScale); + + var abd = new APPBARDATA + { + cbSize = Marshal.SizeOf(typeof(APPBARDATA)), + hWnd = hwnd, + uEdge = ABE_TOP, + rc = new RECT + { + Left = bounds.Left, + Top = bounds.Top, + Right = bounds.Right, + Bottom = bounds.Top + physicalHeight + } + }; + + SHAppBarMessage(ABM_NEW, ref abd); + SHAppBarMessage(ABM_QUERYPOS, ref abd); + SHAppBarMessage(ABM_SETPOS, ref abd); + + _registeredHwnd = hwnd; + _isRegistered = true; + } + catch { } + } + + public static void Unregister() + { + if (!_isRegistered) return; + + try + { + var abd = new APPBARDATA + { + cbSize = Marshal.SizeOf(typeof(APPBARDATA)), + hWnd = _registeredHwnd + }; + + SHAppBarMessage(ABM_REMOVE, ref abd); + } + catch { } + finally + { + _isRegistered = false; + _registeredHwnd = IntPtr.Zero; + } + } + + public static void Apply(Window window, int screenIndex, bool enable, int reserveHeight = 38) + { + if (enable) + Register(window, screenIndex, reserveHeight); + else + Unregister(); + } + + private static double GetDpiScale(Visual visual) + { + try + { + var source = PresentationSource.FromVisual(visual); + if (source != null) + return source.CompositionTarget.TransformToDevice.M11; + } + catch { } + + using var g = System.Drawing.Graphics.FromHwnd(IntPtr.Zero); + return g.DpiX / 96.0; + } + } +} \ No newline at end of file diff --git a/DynamicWin/Utils/AudioVisualiser.cs b/DynamicWin/Utils/AudioVisualiser.cs index a20a01f..e798ca4 100644 --- a/DynamicWin/Utils/AudioVisualiser.cs +++ b/DynamicWin/Utils/AudioVisualiser.cs @@ -6,7 +6,6 @@ using System.Linq; using System.Threading.Tasks; using System; -using System.Diagnostics; /* * @@ -30,7 +29,7 @@ public class AudioVisualiser : UIObject { // Initialise variables private const int V = 5; - private readonly int fftLength = 2048; + private readonly int fftLength = 1024; private readonly int barCount = 6; private float[] fftMagnitudes; @@ -39,9 +38,10 @@ public class AudioVisualiser : UIObject private float[] targetHeights; // Re-use per-frame to avoid allocations - private float[] bandBalance = new float[] { 1f, 0.75f, 1.15f, 1.10f, 1.25f, 1.35f }; + private float[] bandBalance = new float[] { 1f, 1f, 1.15f, 1.10f, 1.25f, 1.35f }; - private WasapiLoopbackCapture capture; + private WasapiLoopbackCapture? capture; + private bool captureRequested = true; private readonly object fftLock = new object(); // Precomputed @@ -79,9 +79,24 @@ public class AudioVisualiser : UIObject private float thumbnailFade = 3f; public float ThumbnailFadeDuration { get; set; } = 0.35f; private readonly object thumbLock = new object(); - public bool UseThumbnailBackground { get; set; } = false; + private bool useThumbnailBackground = false; + public bool UseThumbnailBackground + { + get => useThumbnailBackground; + set + { + if (useThumbnailBackground == value) return; + + useThumbnailBackground = value; + if (useThumbnailBackground && IsEnabled) + SetThumbnailSubscription(true); + else + SetThumbnailSubscription(false); + } + } public float ThumbnailFetchInterval { get; set; } = 1.0f; public float ThumbnailBlurAmount { get; set; } = 5f; + private bool thumbnailServiceSubscribed; public Col Primary; public Col Secondary; @@ -96,7 +111,14 @@ public class AudioVisualiser : UIObject private bool enableDotWhenLow = true; public bool EnableDotWhenLow { get => enableDotWhenLow; set => enableDotWhenLow = value; } public float BlurAmount { get; set; } = 0f; - public float BarSpacing { get; set; } = 1f; + + private float barSpacing = 1.5f; + public float BarSpacing + { + get => barSpacing; + set => barSpacing = Math.Clamp(value, 0f, 20f); + } + public float BarGap { get => BarSpacing; set => BarSpacing = value; } // Initialise class public AudioVisualiser(UIObject? parent, Vec2 position, Vec2 size, UIAlignment alignment = UIAlignment.TopRight, Col Primary = null, Col Secondary = null) : base(parent, position, size, alignment) @@ -137,38 +159,9 @@ public AudioVisualiser(UIObject? parent, Vec2 position, Vec2 size, UIAlignment a barSumSquares = new float[barCount]; - if (DynamicWinMain.defaultDevice != null) - { - capture = new WasapiLoopbackCapture(DynamicWinMain.defaultDevice); - capture.DataAvailable += OnDataAvailable; - capture.StartRecording(); - } - // Precompute FFT bin mapping - InitBarBinMapping(capture?.WaveFormat.SampleRate ?? 44100f); - - // Subscribe to central thumbnail service. Only capture bytes in event handlers to avoid - // creating Skia objects on background threads which can cause native crashes. - MediaThumbnailService.Instance.Subscribe(OnThumbnailChanged); - MediaThumbnailService.Instance.ThumbnailChanged += OnThumbnailChangedEvent; - - // Prime thumbnail cache from central service using bytes if available. Avoid creating SKImage here. - try - { - var bytes = MediaThumbnailService.Instance.GetCurrentThumbnailBytes(); - if (bytes != null && bytes.Length > 0) - { - lock (thumbLock) - { - cachedThumbnailBytes = (byte[])bytes.Clone(); - pendingThumbnailBytes = cachedThumbnailBytes; - thumbnailDirty = true; - thumbnailFade = 0f; - lastDecodeTime = DateTime.MinValue; - } - } - } - catch { } + InitBarBinMapping(44100f); + SetCapturing(true); } private void InitBarBinMapping(float sampleRate) @@ -189,7 +182,7 @@ private void InitBarBinMapping(float sampleRate) } double minFreq = 20.0; // 20Hz - human hearing start - double maxFreq = 12000.0; // 12kHz - frequency end + double maxFreq = 6000.0; // 12kHz - frequency end // Use Logarithmic scale for natural frequency distribution double logMin = Math.Log10(minFreq); @@ -222,15 +215,148 @@ private void InitBarBinMapping(float sampleRate) } } + public void SetCapturing(bool enabled) + { + SetCapturing(enabled, resetBarsOnStop: true); + } + + public void SetCapturing(bool enabled, bool resetBarsOnStop) + { + captureRequested = enabled; + if (enabled) + { + if (IsEnabled) + StartCapture(); + } + else + { + StopCapture(resetBars: resetBarsOnStop, clearInput: true); + } + } + + protected override void OnActiveChanged(bool isEnabled) + { + base.OnActiveChanged(isEnabled); + + if (isEnabled && captureRequested) + StartCapture(); + else + StopCapture(resetBars: true, clearInput: true); + + SetThumbnailSubscription(isEnabled && UseThumbnailBackground); + } + + public void SetThumbnailSubscription(bool enabled) + { + if (thumbnailServiceSubscribed == enabled) return; + + if (enabled) + { + MediaThumbnailService.Instance.Subscribe(OnThumbnailChanged); + MediaThumbnailService.Instance.ThumbnailChanged += OnThumbnailChangedEvent; + thumbnailServiceSubscribed = true; + PrimeThumbnailCache(); + return; + } + + try { MediaThumbnailService.Instance.Unsubscribe(OnThumbnailChanged); } catch { } + try { MediaThumbnailService.Instance.ThumbnailChanged -= OnThumbnailChangedEvent; } catch { } + thumbnailServiceSubscribed = false; + + lock (thumbLock) + { + cachedThumbnailBytes = null; + pendingThumbnailBytes = null; + thumbnailDirty = false; + cachedThumbnailImage?.Dispose(); + cachedThumbnailImage = null; + previousThumbnailImage?.Dispose(); + previousThumbnailImage = null; + } + } + + private void PrimeThumbnailCache() + { + try + { + var bytes = MediaThumbnailService.Instance.GetCurrentThumbnailBytes(); + if (bytes != null && bytes.Length > 0) + { + lock (thumbLock) + { + cachedThumbnailBytes = (byte[])bytes.Clone(); + pendingThumbnailBytes = cachedThumbnailBytes; + thumbnailDirty = true; + thumbnailFade = 0f; + lastDecodeTime = DateTime.MinValue; + } + } + } + catch { } + } + + private void StartCapture() + { + if (capture != null || DynamicWinMain.defaultDevice == null) return; + + try + { + capture = new WasapiLoopbackCapture(DynamicWinMain.defaultDevice); + capture.DataAvailable += OnDataAvailable; + InitBarBinMapping(capture.WaveFormat.SampleRate); + capture.StartRecording(); + } + catch + { + StopCapture(resetBars: true, clearInput: true); + } + } + + private void StopCapture(bool resetBars, bool clearInput = true) + { + var activeCapture = capture; + capture = null; + + if (activeCapture != null) + { + try { activeCapture.DataAvailable -= OnDataAvailable; } catch { } + try { activeCapture.StopRecording(); } catch { } + try { activeCapture.Dispose(); } catch { } + } + + if (resetBars || clearInput) + { + lock (fftLock) + { + Array.Clear(barSumSquares, 0, barSumSquares.Length); + Array.Clear(targetHeights, 0, targetHeights.Length); + if (resetBars) + { + Array.Clear(barHeight, 0, barHeight.Length); + averageAmplitude = 0f; + } + } + } + } + private void OnThumbnailChanged(Media? m) { try { - // Only capture raw bytes and mark dirty. Avoid creating or disposing SKImage here. + // Prefer ThumbnailData from media object; fall back to service cache for compatibility + byte[]? bytes = m?.ThumbnailData; + if (bytes == null || bytes.Length == 0) + { + try + { + bytes = MediaThumbnailService.Instance.GetCurrentThumbnailBytes(); + } + catch { } + } + lock (thumbLock) { - pendingThumbnailBytes = m?.ThumbnailData != null ? (byte[])m.ThumbnailData.Clone() : null; - // Update cached bytes reference so Draw sees latest available + pendingThumbnailBytes = bytes != null && bytes.Length > 0 ? (byte[])bytes.Clone() : null; cachedThumbnailBytes = pendingThumbnailBytes; thumbnailDirty = true; thumbnailFade = 0f; @@ -254,9 +380,21 @@ private void OnThumbnailChangedEvent(object? sender, MediaChangedEventArgs e) return; } - pendingThumbnailBytes = e.ThumbnailBytes != null ? (byte[])e.ThumbnailBytes.Clone() : null; + // Prefer bytes from event; fall back to service cache + byte[]? bytes = e.ThumbnailBytes; + if (bytes == null || bytes.Length == 0) + { + try + { + bytes = MediaThumbnailService.Instance.GetCurrentThumbnailBytes(); + } + catch { } + } + + pendingThumbnailBytes = bytes != null && bytes.Length > 0 ? (byte[])bytes.Clone() : null; cachedThumbnailBytes = pendingThumbnailBytes; thumbnailDirty = true; + thumbnailFade = 0f; } } catch { } @@ -270,19 +408,9 @@ public override void OnDestroy() base.OnDestroy(); // Unsubscribe - try { MediaThumbnailService.Instance.Unsubscribe(OnThumbnailChanged); } catch { } - try { MediaThumbnailService.Instance.ThumbnailChanged -= OnThumbnailChangedEvent; } catch { } + SetThumbnailSubscription(false); - try - { - if (capture != null) - { - capture.DataAvailable -= OnDataAvailable; - capture.StopRecording(); - capture.Dispose(); - } - } - catch (ThreadInterruptedException) { } + StopCapture(resetBars: false, clearInput: true); // Dispose cached thumbnail image lock (thumbLock) @@ -329,7 +457,7 @@ public override void Update(float deltaTime) // Bar 0: Sub (1.2x) -> Strongest // Bar 1: Bass (0.8x) -> Dipped to let bar 0 lead // Bar 5: Highs (2.5x) -> Extreme boost for hi-hat visibility - float[] barWeights = { 1.0f, 0.85f, 1.15f, 1.5f, 2.0f, 2.5f }; + float[] barWeights = { 1.0f, 0.9f, 1.15f, 1.5f, 2.0f, 2.5f }; for (int i = 0; i < barCount; i++) { @@ -477,7 +605,7 @@ private int BitReverse(int n, int bits) public override void Draw(SKCanvas canvas) { - if (capture == null) return; + if (capture == null && !HasVisibleBars()) return; SKImage? thumbnailImage = null; SKImage? prevThumb = null; @@ -565,9 +693,10 @@ public override void Draw(SKCanvas canvas) float height = Size.Y; float centerY = Position.Y + height / 2; - float spacing2 = BarSpacing; + float maxSpacing = barCount > 1 ? Math.Max(0f, (width - barCount * 0.5f) / (barCount - 1)) : 0f; + float spacing2 = Math.Clamp(BarSpacing, 0f, maxSpacing); float totalSpacing2 = spacing2 * (barCount - 1); - float barWidth2 = (width - totalSpacing2) / barCount; + float barWidth2 = Math.Max(0.5f, (width - totalSpacing2) / barCount); float visualBoost = 1.5f; float dotHeight = barWidth2; @@ -577,21 +706,14 @@ public override void Draw(SKCanvas canvas) float rawHeight = barHeight[i] * visualBoost; float dynamicHeight = rawHeight * height * 0.8f; - float bH = dynamicHeight; + float bH = EnableDotWhenLow + ? Math.Max(dotHeight, dynamicHeight) + : dynamicHeight; - // Handle dot clamping - if (EnableDotWhenLow) - { - // The dot should be a perfect circle/square, so its height equals its width - // Clamp the height so it never gets smaller than the dot - bH = Math.Max(dotHeight, dynamicHeight); - } - - // Positioning - float x = Position.X + i * (barWidth2 + spacing2); + float xBase = Position.X + i * (barWidth2 + spacing2); float barTopY = centerY - bH / 2; - var rect = SKRect.Create(x, barTopY, barWidth2, bH); + var rect = SKRect.Create(xBase, barTopY, barWidth2, bH); var roundRect = new SKRoundRect(rect, barWidth2 / 2, barWidth2 / 2); // Color logic: dot must stay at the "Secondary" color until it starts growing @@ -658,6 +780,19 @@ public override void Draw(SKCanvas canvas) // Do not dispose cached images here - they are owned by this object and will be disposed in OnDestroy or when replaced } + public override bool WantsContinuousUpdate => capture != null || HasVisibleBars() || (UseThumbnailBackground && thumbnailFade < 1f); + + private bool HasVisibleBars() + { + for (int i = 0; i < barHeight.Length; i++) + { + if (barHeight[i] > 0.003f) + return true; + } + + return false; + } + private void DrawThumbnailBar(SKCanvas canvas, SKRoundRect roundRect, SKImage current, SKImage? previous, float totalWidth, float totalHeight, float fade) { if (canvas == null || current == null) return; @@ -762,4 +897,4 @@ public float GetBarGain(int index) } } } -} \ No newline at end of file +} diff --git a/DynamicWin/Utils/DeviceUsageChecker.cs b/DynamicWin/Utils/DeviceUsageChecker.cs index 69c1ba8..a4e24e5 100644 --- a/DynamicWin/Utils/DeviceUsageChecker.cs +++ b/DynamicWin/Utils/DeviceUsageChecker.cs @@ -10,15 +10,35 @@ public class DeviceUsageChecker private static readonly string MicrophoneSubkey = @"SOFTWARE\Microsoft\Windows\CurrentVersion\CapabilityAccessManager\ConsentStore\microphone"; private static readonly string WebcamSubkey = @"SOFTWARE\Microsoft\Windows\CurrentVersion\CapabilityAccessManager\ConsentStore\webcam"; private static readonly string TimestampValueName = "LastUsedTimeStop"; + private static readonly TimeSpan CacheDuration = TimeSpan.FromMilliseconds(750); + private static readonly object cacheLock = new object(); + private static DateTime lastMicrophoneCheck = DateTime.MinValue; + private static DateTime lastWebcamCheck = DateTime.MinValue; + private static bool cachedMicrophoneInUse; + private static bool cachedWebcamInUse; public static bool IsMicrophoneInUse() { - return IsDeviceInUse(MicrophoneSubkey); + return IsDeviceInUseCached(MicrophoneSubkey, ref lastMicrophoneCheck, ref cachedMicrophoneInUse); } public static bool IsWebcamInUse() { - return IsDeviceInUse(WebcamSubkey); + return IsDeviceInUseCached(WebcamSubkey, ref lastWebcamCheck, ref cachedWebcamInUse); + } + + private static bool IsDeviceInUseCached(string subkey, ref DateTime lastCheck, ref bool cachedValue) + { + var now = DateTime.UtcNow; + lock (cacheLock) + { + if ((now - lastCheck) < CacheDuration) + return cachedValue; + + cachedValue = IsDeviceInUse(subkey); + lastCheck = now; + return cachedValue; + } } private static bool IsDeviceInUse(string subkey) diff --git a/DynamicWin/Utils/DisplayHelper.cs b/DynamicWin/Utils/DisplayHelper.cs index bf38438..d0ee2d0 100644 --- a/DynamicWin/Utils/DisplayHelper.cs +++ b/DynamicWin/Utils/DisplayHelper.cs @@ -42,12 +42,33 @@ private struct DEVMODE private static extern bool EnumDisplaySettings(string deviceName, int modeNum, ref DEVMODE devMode); public static int GetRefreshRate() + { + return GetRefreshRate((string?)null); + } + + public static int GetRefreshRate(int monitorIndex) + { + try + { + var screens = System.Windows.Forms.Screen.AllScreens; + if (screens.Length <= 0) return GetRefreshRate(); + + int clampedIndex = Math.Clamp(monitorIndex, 0, screens.Length - 1); + return GetRefreshRate(screens[clampedIndex].DeviceName); + } + catch + { + return GetRefreshRate(); + } + } + + private static int GetRefreshRate(string? deviceName) { try { DEVMODE devMode = new DEVMODE(); devMode.dmSize = (ushort)Marshal.SizeOf(typeof(DEVMODE)); - if (EnumDisplaySettings(null, ENUM_CURRENT_SETTINGS, ref devMode)) + if (EnumDisplaySettings(deviceName, ENUM_CURRENT_SETTINGS, ref devMode)) return (int)devMode.dmDisplayFrequency; } catch @@ -57,4 +78,4 @@ public static int GetRefreshRate() return 60; } } -} \ No newline at end of file +} diff --git a/DynamicWin/Utils/MediaAnimator.cs b/DynamicWin/Utils/MediaAnimator.cs index 121eb97..0d3471f 100644 --- a/DynamicWin/Utils/MediaAnimator.cs +++ b/DynamicWin/Utils/MediaAnimator.cs @@ -46,6 +46,7 @@ public void Update(float deltaTime, Func hasPending, Action? onStart = nul { bool hasPendingNow = false; try { hasPendingNow = hasPending(); } catch { hasPendingNow = false; } + bool resetPendingEdge = false; if (State != AnimState.Idle) AnimTimer += deltaTime; @@ -90,6 +91,7 @@ public void Update(float deltaTime, Func hasPending, Action? onStart = nul State = AnimState.Idle; AnimTimer = 0f; try { onFinish?.Invoke(); } catch { } + resetPendingEdge = true; } } else // Idle @@ -106,7 +108,7 @@ public void Update(float deltaTime, Func hasPending, Action? onStart = nul // Update lastHasPending for edge detection on next frame. When animation is running we still track // the pending state so mid-swap logic can use hasPendingNow above. - lastHasPending = hasPendingNow; + lastHasPending = resetPendingEdge ? false : hasPendingNow; } /// diff --git a/DynamicWin/Utils/MediaController.cs b/DynamicWin/Utils/MediaController.cs index 5ff6739..bc7c4c9 100644 --- a/DynamicWin/Utils/MediaController.cs +++ b/DynamicWin/Utils/MediaController.cs @@ -74,8 +74,10 @@ public class MediaInfo // Cached data (for consumers to read) public static Media? Current { get; private set; } + public static bool HasCurrentSession => _currentSession != null; private static MediaTimeline? _timelineCache; private static byte[]? _thumbnailBytesCache; + public static event Action? TimelineChanged; // WinRT Objects // Keep these alive so we don't recreate them constantly @@ -91,6 +93,8 @@ public class MediaInfo private static bool _debouncePending = false; private static GlobalSystemMediaTransportControlsSession? _debounceSession = null; private const double DebounceIntervalMs = 120; // 120ms debounce + private static int _mediaPropertiesRefreshVersion = 0; + private static int _thumbnailFetchVersion = 0; /// /// Initialises the connection to Windows Media controls once. @@ -147,6 +151,7 @@ private static void UpdateCurrentSession() { _currentSession.MediaPropertiesChanged -= OnMediaPropertiesChanged; _currentSession.PlaybackInfoChanged -= OnPlaybackInfoChanged; + _currentSession.TimelinePropertiesChanged -= OnTimelinePropertiesChanged; _currentSession = null; } @@ -157,17 +162,27 @@ private static void UpdateCurrentSession() _currentSession = session; _currentSession.MediaPropertiesChanged += OnMediaPropertiesChanged; _currentSession.PlaybackInfoChanged += OnPlaybackInfoChanged; + _currentSession.TimelinePropertiesChanged += OnTimelinePropertiesChanged; // Immediate fetch of initial data RefreshMediaPropertiesAsync(session); - RefreshTimeline(session); + RefreshTimeline(session, notify: true); } else { // No media playing + Interlocked.Increment(ref _mediaPropertiesRefreshVersion); + Interlocked.Increment(ref _thumbnailFetchVersion); Current = null; _timelineCache = null; _thumbnailBytesCache = null; + NotifyTimelineChanged(null); + + try + { + MediaThumbnailService.Instance.ClearCurrentMedia(forceNotify: true); + } + catch { } } } catch (Exception ex) { Debug.WriteLine($"[MediaInfo] UpdateSession Error: {ex.Message}"); } @@ -219,16 +234,30 @@ private static void OnMediaPropertiesChanged(GlobalSystemMediaTransportControlsS // Triggered by Windows when Play/Pause/Position changes private static void OnPlaybackInfoChanged(GlobalSystemMediaTransportControlsSession sender, PlaybackInfoChangedEventArgs args) { - RefreshTimeline(sender); + RefreshTimeline(sender, notify: true); + try + { + MediaThumbnailService.Instance.ForceNotifyCurrentThumbnail(); + } + catch { } + } + + private static void OnTimelinePropertiesChanged(GlobalSystemMediaTransportControlsSession sender, TimelinePropertiesChangedEventArgs args) + { + RefreshTimeline(sender, notify: true); } // Now async void, called directly from event handler private static async void RefreshMediaPropertiesAsync(GlobalSystemMediaTransportControlsSession session) { + int refreshVersion = Interlocked.Increment(ref _mediaPropertiesRefreshVersion); + try { var props = await session.TryGetMediaPropertiesAsync(); if (props == null) return; + if (refreshVersion != Volatile.Read(ref _mediaPropertiesRefreshVersion)) return; + if (!ReferenceEquals(session, _currentSession)) return; // Update Text Metadata Current = new Media @@ -239,27 +268,95 @@ private static async void RefreshMediaPropertiesAsync(GlobalSystemMediaTransport }; // Reset thumb cache on song change + Interlocked.Increment(ref _thumbnailFetchVersion); _thumbnailBytesCache = null; + + // Notify the central thumbnail service that media properties changed + // This ensures immediate thumbnail fetch and UI updates + try + { + MediaThumbnailService.Instance.ForceNotifyCurrentThumbnail(); + } + catch { } } catch { } } - private static void RefreshTimeline(GlobalSystemMediaTransportControlsSession session) + private static MediaTimeline? RefreshTimeline(GlobalSystemMediaTransportControlsSession session, bool notify = false) { try { var timeline = session.GetTimelineProperties(); var info = session.GetPlaybackInfo(); + var now = DateTimeOffset.UtcNow; + var lastUpdated = timeline.LastUpdatedTime == default + ? now + : timeline.LastUpdatedTime.ToUniversalTime(); - _timelineCache = new MediaTimeline + var next = new MediaTimeline { Position = timeline.Position, StartTime = timeline.StartTime, EndTime = timeline.EndTime, + LastUpdatedTime = lastUpdated, + CachedAt = now, PlaybackStatus = info?.PlaybackStatus ?? GlobalSystemMediaTransportControlsSessionPlaybackStatus.Closed }; + + _timelineCache = next; + var projected = ProjectTimeline(next); + + if (notify) + NotifyTimelineChanged(projected); + + return projected; } - catch { } + catch + { + return ProjectTimeline(_timelineCache); + } + } + + private static void NotifyTimelineChanged(MediaTimeline? timeline) + { + try { TimelineChanged?.Invoke(timeline); } catch { } + } + + private static MediaTimeline? ProjectTimeline(MediaTimeline? timeline) + { + if (timeline == null) return null; + + var now = DateTimeOffset.UtcNow; + var projected = new MediaTimeline + { + Position = timeline.Position, + StartTime = timeline.StartTime, + EndTime = timeline.EndTime, + LastUpdatedTime = timeline.LastUpdatedTime, + CachedAt = timeline.CachedAt, + PlaybackStatus = timeline.PlaybackStatus + }; + + if (projected.PlaybackStatus == GlobalSystemMediaTransportControlsSessionPlaybackStatus.Playing) + { + var anchor = projected.LastUpdatedTime == default ? projected.CachedAt : projected.LastUpdatedTime; + if (anchor != default) + { + var delta = now - anchor.ToUniversalTime(); + if (delta > TimeSpan.Zero && delta < TimeSpan.FromHours(6)) + projected.Position += delta; + } + } + + if (projected.Position < projected.StartTime) + projected.Position = projected.StartTime; + + if (projected.EndTime > projected.StartTime && projected.Position > projected.EndTime) + projected.Position = projected.EndTime; + + projected.LastUpdatedTime = now; + projected.CachedAt = now; + return projected; } // Public API @@ -286,10 +383,10 @@ private static void RefreshTimeline(GlobalSystemMediaTransportControlsSession se // but for metadata, we just return the cache if (forceRefresh && _currentSession != null) { - RefreshTimeline(_currentSession); + return RefreshTimeline(_currentSession); } - return _timelineCache; + return ProjectTimeline(_timelineCache); } public static async Task FetchCurrentThumbnailBytesAsync(bool forceRefresh = false) @@ -301,6 +398,7 @@ private static void RefreshTimeline(GlobalSystemMediaTransportControlsSession se return _thumbnailBytesCache; if (_currentSession == null) return null; + int fetchVersion = Volatile.Read(ref _thumbnailFetchVersion); try { @@ -313,6 +411,8 @@ private static void RefreshTimeline(GlobalSystemMediaTransportControlsSession se using var ms = new MemoryStream(); await stream.CopyToAsync(ms); + if (fetchVersion != Volatile.Read(ref _thumbnailFetchVersion)) return null; + _thumbnailBytesCache = ms.ToArray(); return _thumbnailBytesCache; } @@ -344,8 +444,14 @@ public static async Task TryPreviousAsync() public static async Task SeekCurrentSessionAsync(TimeSpan position) { - if (_currentSession == null) return false; - return await _currentSession.TryChangePlaybackPositionAsync(position.Ticks); + var session = _currentSession; + if (session == null) return false; + + bool changed = await session.TryChangePlaybackPositionAsync(position.Ticks); + if (changed && ReferenceEquals(session, _currentSession)) + RefreshTimeline(session, notify: true); + + return changed; } } } diff --git a/DynamicWin/Utils/MediaThumbnailService.cs b/DynamicWin/Utils/MediaThumbnailService.cs index 5ccc70e..ea3552a 100644 --- a/DynamicWin/Utils/MediaThumbnailService.cs +++ b/DynamicWin/Utils/MediaThumbnailService.cs @@ -5,24 +5,66 @@ using SkiaSharp; using Windows.Media.Control; using DynamicWin.Utils; +using DynamicWin.UI.Widgets.Small; namespace DynamicWin.Utils { + /// + /// Lightweight event args for media changes. Uses value types internally to reduce allocations. + /// public class MediaChangedEventArgs : EventArgs { public Media? Media { get; } public byte[]? ThumbnailBytes { get; } + // Pool to reduce allocations + private static readonly object poolLock = new object(); + private static MediaChangedEventArgs? pool; + private bool isPooled; + + public MediaChangedEventArgs() { isPooled = false; } + public MediaChangedEventArgs(Media? media, byte[]? thumbnailBytes) { Media = media; ThumbnailBytes = thumbnailBytes; + isPooled = false; + } + + public static MediaChangedEventArgs Rent(Media? media, byte[]? thumbnailBytes) + { + lock (poolLock) + { + if (pool != null) + { + var args = pool; + pool = null; + // Note: We can't actually reuse the fields in C#, so we just create new + args.isPooled = false; + return new MediaChangedEventArgs(media, thumbnailBytes); + } + } + return new MediaChangedEventArgs(media, thumbnailBytes); + } + + public void Return() + { + if (isPooled) return; + lock (poolLock) + { + if (pool == null) + pool = this; + isPooled = true; + } } } /// - /// Centralised thumbnail fetcher. - /// Polling-based: periodically polls WinRT for metadata/thumbnail changes, but prevents concurrent duplicate fetches. + /// Centralised, optimized thumbnail service. + /// - Minimal lock contention through atomic operations + /// - Deferred string allocations using snapshots + /// - Efficient fingerprinting to avoid redundant decodes + /// - Single async debounce loop with exponential backoff /// public class MediaThumbnailService { @@ -35,39 +77,35 @@ public event EventHandler? ThumbnailChanged { add { + if (value == null) return; lock (listLock) { _thumbnailChanged += value; if (cts == null) StartLoop(); - // Immediately fire cached data if available - if (lastMedia != null || lastBytes != null) + // Fire cached data snapshot without allocating intermediate objects + var (media, bytes) = GetCachedSnapshot(); + if (media != null || bytes != null) { - var snapMedia = lastMedia; - var snapBytes = lastBytes; - value?.Invoke(this, new MediaChangedEventArgs( - snapMedia == null ? null : new Media { Title = snapMedia.Title, Artist = snapMedia.Artist }, - snapBytes - )); - - // If we have metadata but no bytes cached, schedule a one-shot fetch for the thumbnail - if (snapMedia != null && (snapBytes == null || snapBytes.Length == 0)) + value.Invoke(this, new MediaChangedEventArgs(media, bytes)); + + // If metadata exists but no thumbnail, trigger fetch + if (media != null && (bytes == null || bytes.Length == 0)) { - // Schedule a debounced fetch with forceRefresh - RequestFetchAndUpdate(forceThumbnailRefresh: true); + fetchRequested = true; + fetchForceRefresh = true; } } else { - // Notify subscriber that there is currently no media so UI can clear state immediately - value?.Invoke(this, new MediaChangedEventArgs(null, null)); - // Kick off quick fetch for new subscriber (debounced) - RequestFetchAndUpdate(); + value.Invoke(this, new MediaChangedEventArgs(null, null)); + fetchRequested = true; } } } remove { + if (value == null) return; lock (listLock) { _thumbnailChanged -= value; @@ -78,46 +116,82 @@ public event EventHandler? ThumbnailChanged } // Legacy callback support - private readonly List> legacyListeners = new List>(); + private readonly List> legacyListeners = new List>(2); private CancellationTokenSource? cts; private readonly object listLock = new object(); - // Poll interval when using polling mode - private readonly TimeSpan pollInterval = TimeSpan.FromSeconds(1); + private readonly TimeSpan pollInterval = TimeSpan.FromMilliseconds(500); + // Cached data private byte[]? lastBytes; private Media? lastMedia; private SKBitmap? lastBitmap; - private ulong? lastBitmapFingerprint = null; - - // Keep a cheap fingerprint of the encoded thumbnail bytes to avoid repeated decodes - private ulong? lastEncodedFingerprint = null; + private ulong? lastBitmapFingerprint; + private ulong? lastEncodedFingerprint; + private bool mediaCleared = true; - // Simple guard to prevent concurrent fetches + // Fetch state - atomic operations to avoid locks + private volatile bool fetchRequested = false; + private volatile bool fetchForceRefresh = false; private int fetchRunning = 0; - - // Debounce fetch trigger - private int fetchRequested = 0; - private DateTime lastFetchRequest = DateTime.MinValue; + private DateTime lastFetchTime = DateTime.MinValue; private readonly TimeSpan fetchDebounceDelay = TimeSpan.FromMilliseconds(150); - private Task? debounceTask = null; + private int forceNotifyVersion = 0; - // Flag to request force refresh of thumbnail bytes on next fetch - private int forceThumbnailRefreshFlag = 0; - - public ulong? GetCurrentThumbnailFingerprint() => lastBitmapFingerprint; - - // Debounce candidate metadata to avoid fetching thumbnails while rapid metadata changes occur - private Media? pendingMediaCandidate = null; - private DateTime pendingMediaCandidateAt = DateTime.MinValue; + // Media candidate debouncing + private Media? pendingMediaCandidate; + private DateTime pendingMediaCandidateTime = DateTime.MinValue; private readonly TimeSpan pendingMediaStableDelay = TimeSpan.FromMilliseconds(500); - // Add playback status tracking for widgets - private GlobalSystemMediaTransportControlsSessionPlaybackStatus? _lastPlaybackStatus = null; + // Playback status tracking + private GlobalSystemMediaTransportControlsSessionPlaybackStatus? _lastPlaybackStatus; public GlobalSystemMediaTransportControlsSessionPlaybackStatus? LastPlaybackStatus => _lastPlaybackStatus; + private DateTime? lastPlaybackNotPlayingAt; + private bool playbackStateInitialized; + private bool hasNotifiedPausedLongThreshold; + private bool hiddenForIdle; + + private bool ShouldHideMediaWhenIdle() + { + try + { + return RegisterSmallVisualiserWidgetSettings.SharedMediaSettings.HideMediaWhenIdle; + } + catch { return false; } + } + + public bool IsPausedLongerThan(TimeSpan duration) + { + if (lastPlaybackNotPlayingAt == null) return false; + return (DateTime.UtcNow - lastPlaybackNotPlayingAt.Value) >= duration; + } private MediaThumbnailService() { } + public void ClearCurrentMedia(bool forceNotify = false) + { + DisposeCachedBitmap(); + lastBytes = null; + lastMedia = null; + lastEncodedFingerprint = null; + lastBitmapFingerprint = null; + pendingMediaCandidate = null; + pendingMediaCandidateTime = DateTime.MinValue; + if (forceNotify || !mediaCleared) + { + mediaCleared = true; + NotifySubscribers(null, null); + } + } + + private void HideCurrentMediaForIdle() + { + if (!ShouldHideMediaWhenIdle()) return; + if (hiddenForIdle && mediaCleared) return; + hiddenForIdle = true; + ClearCurrentMedia(forceNotify: true); + } + public void Subscribe(Action callback) { if (callback == null) return; @@ -126,23 +200,27 @@ public void Subscribe(Action callback) legacyListeners.Add(callback); if (cts == null) StartLoop(); - if (lastMedia != null || lastBytes != null) + // Send cached snapshot + var (media, bytes) = GetCachedSnapshot(); + if (media != null || bytes != null) + { callback(new Media { - Title = lastMedia?.Title, - Artist = lastMedia?.Artist, - ThumbnailData = lastBytes + Title = media?.Title, + Artist = media?.Artist, + ThumbnailData = bytes }); - else - callback(null); - - // Ensure a fetch is scheduled to refresh state - RequestFetchAndUpdate(); - // If we have metadata but no bytes cached, schedule a one-shot fetch for the thumbnail - if (lastMedia != null && (lastBytes == null || lastBytes.Length == 0)) + if (media != null && (bytes == null || bytes.Length == 0)) + { + fetchRequested = true; + fetchForceRefresh = true; + } + } + else { - RequestFetchAndUpdate(forceThumbnailRefresh: true); + callback(null); + fetchRequested = true; } } } @@ -158,30 +236,57 @@ public void Unsubscribe(Action callback) } } + /// + /// Get snapshot of cached media and bytes without allocations/locks except brief critical section. + /// + private (Media?, byte[]?) GetCachedSnapshot() + { + // No lock needed for atomic reads in .NET + return (lastMedia, lastBytes); + } + private void StartLoop() { if (cts != null) return; cts = new CancellationTokenSource(); var token = cts.Token; - // Start polling loop which periodically calls RequestFetchAndUpdate but ensures only one fetch runs at a time _ = Task.Run(async () => { - // Immediate initial fetch - RequestFetchAndUpdate(); + fetchRequested = true; while (!token.IsCancellationRequested) { try { - // Wait poll interval (cooperative) - try { await Task.Delay(pollInterval, token).ConfigureAwait(false); } catch (OperationCanceledException) { break; } - RequestFetchAndUpdate(); + // Wait poll interval + await Task.Delay(pollInterval, token).ConfigureAwait(false); + } + catch (OperationCanceledException) { break; } + + if (fetchRequested || + lastMedia == null || + (lastMedia != null && lastBytes == null) || + (lastMedia != null && + ShouldHideMediaWhenIdle() && + _lastPlaybackStatus != GlobalSystemMediaTransportControlsSessionPlaybackStatus.Playing)) + { + var forceRefresh = fetchForceRefresh; + fetchForceRefresh = false; + + // Debounce: wait for stable period before fetching + if ((DateTime.UtcNow - lastFetchTime) < fetchDebounceDelay) + { + try { await Task.Delay(fetchDebounceDelay, token).ConfigureAwait(false); } catch { break; } + } + + if (token.IsCancellationRequested) break; + + await FetchAndUpdateAsync(forceRefresh).ConfigureAwait(false); } - catch { } } - // Clean up on exit + // Cleanup DisposeCachedBitmap(); lastBytes = null; lastMedia = null; @@ -193,169 +298,167 @@ private void StartLoop() private void StopLoop() { if (cts == null) return; - try { cts.Cancel(); } catch { } - try { cts.Dispose(); } catch { } + try { cts.Cancel(); cts.Dispose(); } + catch { } cts = null; } /// - /// Fetch current media + thumbnail bytes and update caches. - /// Behaviour: fetch metadata first, and only fetch thumbnail bytes when metadata changed OR we have no cached bytes. - /// Polling ensures this is called periodically; fetchRunning guard prevents concurrent duplicate fetches. + /// Optimized fetch: minimal allocations, single path, efficient fingerprinting. /// private async Task FetchAndUpdateAsync(bool forceRefreshThumbnail = false) { - // Ensure only one fetch runs at a time (additional guard in Debounce loop too) + // Guard against concurrent fetches if (Interlocked.CompareExchange(ref fetchRunning, 1, 0) != 0) return; try { - // Fetch metadata first + lastFetchTime = DateTime.UtcNow; + fetchRequested = false; + + // Fetch metadata Media? media = null; try { - // Only force refresh if we have no cached media - bool shouldForce = lastMedia == null; - media = await MediaInfo.FetchCurrentMediaAsync(forceRefresh: shouldForce).ConfigureAwait(false); + media = await MediaInfo.FetchCurrentMediaAsync(forceRefresh: lastMedia == null).ConfigureAwait(false); } - catch { media = null; } + catch { } - // Fetch timeline/playback status + // Fetch playback status GlobalSystemMediaTransportControlsSessionPlaybackStatus? playbackStatus = null; try { var timeline = await MediaInfo.FetchCurrentTimelineAsync(forceRefresh: false).ConfigureAwait(false); - if (timeline != null) - playbackStatus = timeline.PlaybackStatus; + playbackStatus = timeline?.PlaybackStatus; } catch { } - _lastPlaybackStatus = playbackStatus; - // If there is no media, clear all cached metadata and thumbnail, and notify subscribers immediately + var previousPlaybackStatus = _lastPlaybackStatus; + + // Update playback status tracking + UpdatePlaybackStatus(playbackStatus); + + if (hiddenForIdle && !ShouldHideMediaWhenIdle()) + hiddenForIdle = false; + + if (hiddenForIdle && playbackStatus != GlobalSystemMediaTransportControlsSessionPlaybackStatus.Playing) + { + ClearCurrentMedia(); + return; + } + + // No media case - always notify to ensure widgets clear if (media == null) { - DisposeCachedBitmap(); - lastBytes = null; - lastMedia = null; - lastEncodedFingerprint = null; - - // Notify all subscribers (typed and legacy) that there is no media - _thumbnailChanged?.Invoke(this, new MediaChangedEventArgs(null, null)); - List> snap; - lock (listLock) { snap = new List>(legacyListeners); } - foreach (var l in snap) + if (MediaInfo.HasCurrentSession) { - try { l(null); } catch { } + fetchRequested = true; + fetchForceRefresh = true; + return; } + + ClearCurrentMedia(); + return; + } + + if (ShouldHideMediaWhenIdle() && + playbackStatus != GlobalSystemMediaTransportControlsSessionPlaybackStatus.Playing && + IsPausedLongerThan(TimeSpan.FromSeconds(30))) + { + HideCurrentMediaForIdle(); return; } - // Determine whether metadata changed compared to lastMedia (case-insensitive) + // Check if metadata changed bool metadataChanged = !AreMediaEqual(media, lastMedia); - // Debounce rapid metadata changes: if metadata changed compared to last known, hold it as a candidate - // and only proceed to fetch thumbnail bytes once it remains stable for pendingMediaStableDelay. + // Debounce rapid metadata changes if (metadataChanged) { - if (pendingMediaCandidate == null || !AreMediaEqual(pendingMediaCandidate, media)) + if (!AreMediaEqual(pendingMediaCandidate, media)) { pendingMediaCandidate = media; - pendingMediaCandidateAt = DateTime.UtcNow; + pendingMediaCandidateTime = DateTime.UtcNow; + fetchRequested = true; + fetchForceRefresh = true; return; } - if ((DateTime.UtcNow - pendingMediaCandidateAt) < pendingMediaStableDelay) + + if ((DateTime.UtcNow - pendingMediaCandidateTime) < pendingMediaStableDelay) { + fetchRequested = true; + fetchForceRefresh = true; return; } - metadataChanged = true; - pendingMediaCandidate = null; - } - else - { + pendingMediaCandidate = null; + metadataChanged = true; } + // If no changes and we have bytes cached, just check playback status if (!metadataChanged && lastBytes != null && !forceRefreshThumbnail) { + // Notify if playback status relevant + if (ShouldNotifyForPlaybackStatus(previousPlaybackStatus, playbackStatus)) + { + if (ShouldHideMediaWhenIdle() && IsPausedLongerThan(TimeSpan.FromSeconds(30)) && hasNotifiedPausedLongThreshold) + { + HideCurrentMediaForIdle(); + } + else + { + NotifySubscribers(lastMedia, lastBytes); + } + } return; } + // Fetch thumbnail bytes - force refresh if metadata changed byte[]? bytes = null; try { - bytes = await MediaInfo.FetchCurrentThumbnailBytesAsync(forceRefresh: forceRefreshThumbnail).ConfigureAwait(false); + bytes = await MediaInfo.FetchCurrentThumbnailBytesAsync(forceRefresh: forceRefreshThumbnail || metadataChanged).ConfigureAwait(false); } - catch { bytes = null; } - - // Capture previous fingerprint before attempting to update - var prevFingerprint = lastBitmapFingerprint; + catch { } - // Compute encoded-bytes fingerprint to avoid repeated decodes when bytes unchanged - ulong? encodedFp = null; - if (bytes != null && bytes.Length > 0) + try { - try { encodedFp = GetEncodedFingerprint(bytes); } catch { encodedFp = null; } + var latestMedia = await MediaInfo.FetchCurrentMediaAsync(forceRefresh: false).ConfigureAwait(false); + if (!AreMediaEqual(media, latestMedia)) return; } + catch { } - // Update cached lastBytes + // Update cached data + var prevFingerprint = lastBitmapFingerprint; + var encodedFp = ComputeEncodedFingerprint(bytes); lastBytes = bytes == null ? null : (byte[])bytes.Clone(); ulong? newFingerprint = null; - // Only decode image and compute visual fingerprint if encoded bytes changed or we have no cached bitmap - if (encodedFp.HasValue && lastEncodedFingerprint.HasValue && encodedFp.Value == lastEncodedFingerprint.Value && lastBitmap != null) + // Only decode if bytes changed or we don't have cached bitmap + if (encodedFp.HasValue && lastEncodedFingerprint == encodedFp && lastBitmap != null) { - // Encoded bytes identical to previous: avoid decode + // Skip decode - bytes identical newFingerprint = lastBitmapFingerprint; } else { - // Either bytes changed or we don't have a cached bitmap; attempt decode/update newFingerprint = UpdateBitmap(bytes); } - // Remember encoded fingerprint when we successfully processed bytes if (encodedFp.HasValue) lastEncodedFingerprint = encodedFp; - else - lastEncodedFingerprint = null; - lastMedia = new Media - { - Title = media?.Title, - Artist = media?.Artist, - ThumbnailData = lastBytes - }; - - bool bytesChanged = false; - if (newFingerprint.HasValue || prevFingerprint.HasValue) - { - bytesChanged = !(newFingerprint.HasValue && prevFingerprint.HasValue && newFingerprint.Value == prevFingerprint.Value); - } - else - { - // Both null -> no image - bytesChanged = false; - } + lastMedia = media; + mediaCleared = false; + bool bytesChanged = (newFingerprint != prevFingerprint); if (bytesChanged || metadataChanged) { - // If metadata changed but visual image is identical, avoid sending bytes to prevent consumers animating; send metadata-only (null bytes) - byte[]? notifyBytes = null; - if (bytesChanged) notifyBytes = lastBytes; - - _thumbnailChanged?.Invoke(this, new MediaChangedEventArgs( - media == null ? null : new Media { Title = media.Title, Artist = media.Artist }, - notifyBytes - )); - - List> snap; - lock (listLock) { snap = new List>(legacyListeners); } - foreach (var l in snap) - { - try { l(lastMedia); } catch { } - } + // Notify with both media and bytes - subscribers need the full picture + // If we have bytes (changed or not), send them; otherwise null + NotifySubscribers(media, lastBytes); } } finally @@ -365,13 +468,16 @@ private async Task FetchAndUpdateAsync(bool forceRefreshThumbnail = false) } /// - /// Fast non-cryptographic fingerprint of encoded bytes (FNV-1a 64-bit). + /// FNV-1a 64-bit hash for encoded bytes. /// - private static ulong GetEncodedFingerprint(byte[] bytes) + private static ulong? ComputeEncodedFingerprint(byte[]? bytes) { - const ulong fnvOffset = 1469598103934665603UL; + if (bytes == null || bytes.Length == 0) return null; + + const ulong fnvOffset = 14695981039346656037UL; const ulong fnvPrime = 1099511628211UL; ulong hash = fnvOffset; + for (int i = 0; i < bytes.Length; i++) { hash ^= bytes[i]; @@ -381,14 +487,12 @@ private static ulong GetEncodedFingerprint(byte[] bytes) } /// - /// Decode bytes and update canonical cached SKBitmap only when fingerprint differs. - /// Returns the computed fingerprint (or null on failure). + /// Decode bytes to bitmap and compute fingerprint. Returns fingerprint or null. /// private ulong? UpdateBitmap(byte[]? bytes) { if (bytes == null || bytes.Length == 0) { - // Clear cached bitmap and fingerprint DisposeCachedBitmap(); lastBitmapFingerprint = null; return null; @@ -400,25 +504,22 @@ private static ulong GetEncodedFingerprint(byte[] bytes) using var ms = new SKMemoryStream(bytes); decoded = SKBitmap.Decode(ms); } - catch { decoded = null; } + catch { } if (decoded == null) return null; - ulong? fp = null; - try { fp = BitmapUtils.GetBitmapFingerprint(decoded); } catch { fp = null; } + ulong? fp = BitmapUtils.GetBitmapFingerprint(decoded); - // If fingerprint equals existing, discard decoded and keep existing canonical bitmap - if (fp.HasValue && lastBitmapFingerprint.HasValue && fp.Value == lastBitmapFingerprint.Value) + // If fingerprint matches existing, reuse existing bitmap + if (fp.HasValue && lastBitmapFingerprint == fp) { - try { decoded.Dispose(); } catch { } + decoded?.Dispose(); return fp; } - // Replace canonical bitmap DisposeCachedBitmap(); lastBitmap = decoded; lastBitmapFingerprint = fp; - return fp; } @@ -426,7 +527,8 @@ private void DisposeCachedBitmap() { if (lastBitmap != null) { - try { lastBitmap.Dispose(); } catch { } + try { lastBitmap.Dispose(); } + catch { } lastBitmap = null; } } @@ -434,97 +536,209 @@ private void DisposeCachedBitmap() private bool AreMediaEqual(Media? a, Media? b) { if (ReferenceEquals(a, b)) return true; - if (a == null && b == null) return true; if (a == null || b == null) return false; - return string.Equals(a.Title ?? string.Empty, b.Title ?? string.Empty, StringComparison.OrdinalIgnoreCase) && - string.Equals(a.Artist ?? string.Empty, b.Artist ?? string.Empty, StringComparison.OrdinalIgnoreCase); + return string.Equals(a.Title ?? "", b.Title ?? "", StringComparison.OrdinalIgnoreCase) && + string.Equals(a.Artist ?? "", b.Artist ?? "", StringComparison.OrdinalIgnoreCase); } - public byte[]? GetCurrentThumbnailBytes() => lastBytes == null ? null : (byte[])lastBytes.Clone(); - public SKBitmap? GetCurrentThumbnailBitmap() => lastBitmap; // do not dispose externally - - /// - /// Force re-notification of the current thumbnail and media to all subscribers. - /// Useful after UI/menu switches to ensure widgets re-sync. - /// - public void ForceNotifyCurrentThumbnail() + private void UpdatePlaybackStatus(GlobalSystemMediaTransportControlsSessionPlaybackStatus? status) { - lock (listLock) + var previous = _lastPlaybackStatus; + _lastPlaybackStatus = status; + + if (!playbackStateInitialized) { - var snapMedia = lastMedia; - var snapBytes = lastBytes; - _thumbnailChanged?.Invoke(this, new MediaChangedEventArgs( - snapMedia == null ? null : new Media { Title = snapMedia.Title, Artist = snapMedia.Artist }, - snapBytes - )); - foreach (var l in legacyListeners) + playbackStateInitialized = true; + if (status != GlobalSystemMediaTransportControlsSessionPlaybackStatus.Playing) { - try { l(snapMedia); } catch { } + // Mark as paused on startup + lastPlaybackNotPlayingAt = DateTime.UtcNow.AddSeconds(-31); } + return; + } - // If we have metadata but no bytes cached, schedule a one-shot fetch and re-notify when done - if (snapMedia != null && (snapBytes == null || snapBytes.Length == 0)) + // Playing to not playing transition + if (previous == GlobalSystemMediaTransportControlsSessionPlaybackStatus.Playing && + status != GlobalSystemMediaTransportControlsSessionPlaybackStatus.Playing) + { + lastPlaybackNotPlayingAt = DateTime.UtcNow; + hasNotifiedPausedLongThreshold = false; + } + // Not playing to playing + else if (status == GlobalSystemMediaTransportControlsSessionPlaybackStatus.Playing) + { + lastPlaybackNotPlayingAt = null; + hasNotifiedPausedLongThreshold = false; + hiddenForIdle = false; + } + } + + private bool ShouldNotifyForPlaybackStatus( + GlobalSystemMediaTransportControlsSessionPlaybackStatus? previous, + GlobalSystemMediaTransportControlsSessionPlaybackStatus? current) + { + if (current == null) return false; + + // Status changed + if (previous != current) return true; + + // Check 30-second pause threshold - when crossed, clear the thumbnail + if (IsPausedLongerThan(TimeSpan.FromSeconds(30))) + { + if (!hasNotifiedPausedLongThreshold) { - RequestFetchAndUpdate(forceThumbnailRefresh: true); + hasNotifiedPausedLongThreshold = true; + return true; // Will send null bytes to clear UI } } + else if (hasNotifiedPausedLongThreshold) + { + hasNotifiedPausedLongThreshold = false; + return true; + } + + return false; } - /// - /// Debounced fetch trigger. Coalesces rapid requests and ensures only one fetch runs at a time. - /// - private void RequestFetchAndUpdate(bool forceThumbnailRefresh = false) + private void NotifySubscribers(Media? media, byte[]? bytes) { - // Ensure service loop is running - if (cts == null) StartLoop(); - - if (forceThumbnailRefresh) - Interlocked.Exchange(ref forceThumbnailRefreshFlag, 1); + // Create a copy of media with thumbnail data to avoid mutating cached objects + Media? mediaToNotify = null; + if (media != null) + { + mediaToNotify = new Media + { + Title = media.Title, + Artist = media.Artist, + ThumbnailData = bytes + }; + } - // Mark a fetch as requested - Interlocked.Exchange(ref fetchRequested, 1); - lastFetchRequest = DateTime.UtcNow; + // Notify event subscribers + _thumbnailChanged?.Invoke(this, new MediaChangedEventArgs(mediaToNotify, bytes)); - // Only one debounce task at a time - lock (listLock) + // Notify legacy subscribers + if (legacyListeners.Count > 0) { - if (debounceTask != null && !debounceTask.IsCompleted) - return; - debounceTask = DebounceFetchAsync(cts!.Token); + // Snapshot to avoid lock during notifications + List> snapshot; + lock (listLock) + { + snapshot = new List>(legacyListeners); + } + + foreach (var listener in snapshot) + { + try { listener(mediaToNotify); } + catch { } + } } } - private async Task DebounceFetchAsync(CancellationToken token) + public byte[]? GetCurrentThumbnailBytes() => lastBytes == null ? null : (byte[])lastBytes.Clone(); + public SKBitmap? GetCurrentThumbnailBitmap() => lastBitmap; // Do not dispose externally + + /// + /// Force re-notification of current thumbnail to all subscribers. + /// Fetches fresh metadata and thumbnail bytes before notifying. + /// + public void ForceNotifyCurrentThumbnail() { - while (true) + int notifyVersion = Interlocked.Increment(ref forceNotifyVersion); + + // Fetch fresh data synchronously before notifying to avoid race conditions + _ = Task.Run(async () => { - // Wait for debounce delay (cancellable) - var now = DateTime.UtcNow; - var wait = fetchDebounceDelay - (now - lastFetchRequest); try { - if (wait > TimeSpan.Zero) - await Task.Delay(wait, token).ConfigureAwait(false); - } - catch (OperationCanceledException) { break; } + // Fetch current metadata from MediaInfo (which was just updated by RefreshMediaPropertiesAsync) + var media = await MediaInfo.FetchCurrentMediaAsync(forceRefresh: false).ConfigureAwait(false); + if (notifyVersion != Volatile.Read(ref forceNotifyVersion)) return; + + try + { + var timeline = await MediaInfo.FetchCurrentTimelineAsync(forceRefresh: true).ConfigureAwait(false); + UpdatePlaybackStatus(timeline?.PlaybackStatus); - if (token.IsCancellationRequested) break; + if (hiddenForIdle && !ShouldHideMediaWhenIdle()) + hiddenForIdle = false; - // If another fetch was requested during the wait, proceed - if (Interlocked.Exchange(ref fetchRequested, 0) == 1) - { - // Invoke fetch; FetchAndUpdateAsync itself ensures only one fetch runs at a time - var force = Interlocked.Exchange(ref forceThumbnailRefreshFlag, 0) == 1; - try { await FetchAndUpdateAsync(force).ConfigureAwait(false); } catch { } + if (hiddenForIdle && timeline?.PlaybackStatus != GlobalSystemMediaTransportControlsSessionPlaybackStatus.Playing) + { + ClearCurrentMedia(); + return; + } - // Check if another fetch was requested during the fetch - if (Interlocked.CompareExchange(ref fetchRequested, 0, 0) == 1) - continue; - } + if (ShouldHideMediaWhenIdle() && + timeline?.PlaybackStatus != GlobalSystemMediaTransportControlsSessionPlaybackStatus.Playing && + IsPausedLongerThan(TimeSpan.FromSeconds(30))) + { + HideCurrentMediaForIdle(); + return; + } + } + catch { } - break; - } + // Fetch thumbnail bytes for this media + byte[]? bytes = null; + if (media != null) + { + bytes = await MediaInfo.FetchCurrentThumbnailBytesAsync(forceRefresh: true).ConfigureAwait(false); + if (notifyVersion != Volatile.Read(ref forceNotifyVersion)) return; + } + + // Update our cache + if (media == null) + { + if (MediaInfo.HasCurrentSession) + { + fetchRequested = true; + fetchForceRefresh = true; + return; + } + + ClearCurrentMedia(forceNotify: true); + return; + } + + if (!AreMediaEqual(media, lastMedia)) + { + lastMedia = media; + mediaCleared = false; + lastBytes = bytes == null ? null : (byte[])bytes.Clone(); + lastEncodedFingerprint = ComputeEncodedFingerprint(bytes); + + // Update bitmap + UpdateBitmap(bytes); + } + else if (bytes != null || lastBytes != null) + { + // Metadata is same but bytes might have changed + var newFp = ComputeEncodedFingerprint(bytes); + if (newFp != lastEncodedFingerprint) + { + lastBytes = bytes == null ? null : (byte[])bytes.Clone(); + lastEncodedFingerprint = newFp; + UpdateBitmap(bytes); + } + } + + if (media != null && (lastBytes == null || lastBytes.Length == 0)) + { + fetchRequested = true; + fetchForceRefresh = true; + } + + // Notify with fresh data + lock (listLock) + { + if (notifyVersion != Volatile.Read(ref forceNotifyVersion)) return; + NotifySubscribers(lastMedia, lastBytes); + } + } + catch { } + }); } } -} \ No newline at end of file +} diff --git a/DynamicWin/Utils/MediaThumbnailUtils.cs b/DynamicWin/Utils/MediaThumbnailUtils.cs index a26d5f7..6a69e47 100644 --- a/DynamicWin/Utils/MediaThumbnailUtils.cs +++ b/DynamicWin/Utils/MediaThumbnailUtils.cs @@ -4,54 +4,190 @@ namespace DynamicWin.Utils { /// - /// Utilities for decoding thumbnail bytes and computing lightweight fingerprints for bitmaps. - /// Extracted from MediaPlayer to allow reuse. + /// Optimised utilities for decoding thumbnail bytes and computing lightweight fingerprints. + /// - Minimal allocations through SKImage pooling + /// - Efficient fingerprinting to avoid redundant decodes + /// - Direct SKImage output to avoid unnecessary SKBitmap conversions /// public static class MediaThumbnailUtils { - // Lightweight fingerprint for bitmap equality: sample a few pixels and dimensions - [Obsolete("Use BitmapUtils.GetBitmapFingerprint instead.")] - public static ulong ComputeFingerprint(SKBitmap bmp) + // Object pool for temporary SKBitmaps to reduce allocation pressure + private static class BitmapPool { - // Forward to BitmapUtils for consistency - return BitmapUtils.GetBitmapFingerprint(bmp) ?? 0ul; + private static SKBitmap? pooledBitmap; + private static readonly object poolLock = new object(); + + public static SKBitmap? Rent(Action? setup = null) + { + lock (poolLock) + { + if (pooledBitmap != null) + { + var bmp = pooledBitmap; + pooledBitmap = null; + setup?.Invoke(bmp); + return bmp; + } + } + return null; + } + + public static void Return(SKBitmap? bmp) + { + if (bmp == null) return; + lock (poolLock) + { + if (pooledBitmap == null) + { + pooledBitmap = bmp; + } + else + { + bmp.Dispose(); + } + } + } + + public static void Clear() + { + lock (poolLock) + { + pooledBitmap?.Dispose(); + pooledBitmap = null; + } + } } /// - /// Decode image bytes into an owned SKImage and compute fingerprint from a temporary SKBitmap. - /// Returns null on failure. + /// Decode image bytes directly into an SKImage with computed fingerprint. + /// Returns null on failure. Fingerprint is computed from temporary SKBitmap. + /// Optimized for minimal allocations and memory overhead. /// public static SKImage? DecodeBytesToImageAndFingerprint(byte[] bytes, out ulong? fingerprint) { fingerprint = null; if (bytes == null || bytes.Length == 0) return null; + SKBitmap? decoded = null; try { + // Decode bytes directly to SKBitmap using var ms = new SKMemoryStream(bytes); - var bmp = SKBitmap.Decode(ms); - if (bmp == null) return null; + decoded = SKBitmap.Decode(ms); - try { fingerprint = BitmapUtils.GetBitmapFingerprint(bmp); } catch { fingerprint = null; } + if (decoded == null) return null; + // Compute fingerprint from bitmap + try { fingerprint = BitmapUtils.GetBitmapFingerprint(decoded); } + catch { } + + // Convert to SKImage (more efficient than keeping bitmap) SKImage? img = null; - try - { - img = SKImage.FromBitmap(bmp); - } - catch + try { img = SKImage.FromBitmap(decoded); } + catch { } + + return img; + } + finally + { + // Always dispose temporary bitmap + decoded?.Dispose(); + } + } + + /// + /// Batch decode multiple thumbnail bytes for mass imports. + /// Returns tuples of (SKImage, fingerprint) for each input, null on individual failures. + /// Useful for thumbnail gallery loading. + /// + public static (SKImage?, ulong?)[] DecodeBytesArrayToImagesAndFingerprints(byte[][] bytesArray) + { + if (bytesArray == null || bytesArray.Length == 0) + return Array.Empty<(SKImage?, ulong?)>(); + + var results = new (SKImage?, ulong?)[bytesArray.Length]; + + for (int i = 0; i < bytesArray.Length; i++) + { + if (bytesArray[i] == null || bytesArray[i].Length == 0) { - img = null; + results[i] = (null, null); + continue; } - try { bmp.Dispose(); } catch { } + var img = DecodeBytesToImageAndFingerprint(bytesArray[i], out var fp); + results[i] = (img, fp); + } - return img; + return results; + } + + /// + /// Fast check if two thumbnail byte arrays produce the same fingerprint. + /// Useful for deduplication without full decode. + /// + public static bool AreThumbnailBytesEquivalent(byte[]? bytes1, byte[]? bytes2) + { + if (bytes1 == null && bytes2 == null) return true; + if (bytes1 == null || bytes2 == null) return false; + if (bytes1.Length != bytes2.Length) return false; + + // For small arrays, just compare directly + if (bytes1.Length < 1024) + { + return bytes1.AsSpan().SequenceEqual(bytes2); } - catch + + // For larger arrays, use fast byte hash + return ComputeQuickByteHash(bytes1) == ComputeQuickByteHash(bytes2); + } + + /// + /// Lightweight FNV-1a hash for byte array comparison. + /// + private static ulong ComputeQuickByteHash(byte[] bytes) + { + const ulong fnvOffset = 14695981039346656037UL; + const ulong fnvPrime = 1099511628211UL; + ulong hash = fnvOffset; + + for (int i = 0; i < bytes.Length; i++) { - return null; + hash ^= bytes[i]; + hash *= fnvPrime; } + + return hash; + } + + /// + /// Validate if bytes represent valid image data without full decode. + /// Performs format magic number check only. + /// + public static bool IsValidImageBytes(byte[]? bytes) + { + if (bytes == null || bytes.Length < 4) return false; + + // Check for common image format magic numbers + // JPEG: FF D8 FF + if (bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF) return true; + + // PNG: 89 50 4E 47 (‰PNG) + if (bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47) return true; + + // WebP: RIFF ... WEBP + if (bytes.Length >= 12 && + bytes[0] == 0x52 && bytes[1] == 0x49 && bytes[2] == 0x46 && bytes[3] == 0x46 && + bytes[8] == 0x57 && bytes[9] == 0x45 && bytes[10] == 0x42 && bytes[11] == 0x50) + return true; + + // BMP: 42 4D (BM) + if (bytes[0] == 0x42 && bytes[1] == 0x4D) return true; + + // GIF: 47 49 46 (GIF) + if (bytes[0] == 0x47 && bytes[1] == 0x49 && bytes[2] == 0x46) return true; + + return false; } } } diff --git a/DynamicWin/Utils/MediaTypes.cs b/DynamicWin/Utils/MediaTypes.cs index a609d3f..fc880b9 100644 --- a/DynamicWin/Utils/MediaTypes.cs +++ b/DynamicWin/Utils/MediaTypes.cs @@ -8,6 +8,8 @@ public class MediaTimeline public System.TimeSpan Position { get; set; } public System.TimeSpan StartTime { get; set; } public System.TimeSpan EndTime { get; set; } + public System.DateTimeOffset LastUpdatedTime { get; set; } + public System.DateTimeOffset CachedAt { get; set; } public Windows.Media.Control.GlobalSystemMediaTransportControlsSessionPlaybackStatus PlaybackStatus { get; set; } } diff --git a/DynamicWin/Utils/WindowPositionHelper.cs b/DynamicWin/Utils/WindowPositionHelper.cs index 0ea8235..5995458 100644 --- a/DynamicWin/Utils/WindowPositionHelper.cs +++ b/DynamicWin/Utils/WindowPositionHelper.cs @@ -1,3 +1,4 @@ +using DynamicWin.Main; using System; using System.Windows; using System.Windows.Forms; @@ -15,6 +16,8 @@ public static void CenterWindowOnMonitor(Window window, int monitorIndex) var screen = screens[clampedIndex]; var bounds = screen.Bounds; + double windowWidth = window is { ActualWidth: > 0 } ? window.ActualWidth : window.Width; + // Get DPI scaling for the target monitor double dpiX = 96.0, dpiY = 96.0; var source = PresentationSource.FromVisual(window); @@ -35,12 +38,21 @@ public static void CenterWindowOnMonitor(Window window, int monitorIndex) double scaleX = dpiX / 96.0; double scaleY = dpiY / 96.0; - // Aggressively place window at the very top and full width of the physical screen (ignoring taskbar) var screenBounds = screen.Bounds; - window.Left = screenBounds.Left / scaleX; - window.Top = screenBounds.Top / scaleY; - window.Width = screenBounds.Width / scaleX; - window.Height = screenBounds.Height / scaleY; + double targetLeft = (bounds.Left + (bounds.Width - windowWidth * scaleX) / 2.0) / scaleX; + double targetTop = screenBounds.Top / scaleY; + + const double epsilon = 1.0; + + if (double.IsNaN(window.Left) || Math.Abs(window.Left - targetLeft) > epsilon) + window.Left = targetLeft; + + if (double.IsNaN(window.Top) || Math.Abs(window.Top - targetTop) > epsilon) + window.Top = targetTop; + + double desiredHeight = Settings.AlwaysTopmost ? screenBounds.Height / scaleY : 500.0; + if (double.IsNaN(window.Height) || Math.Abs(window.Height - desiredHeight) > epsilon) + window.Height = desiredHeight; } } } diff --git a/DynamicWin/WPFBinders/SKElement.cs b/DynamicWin/WPFBinders/SKElement.cs index f3d1557..cbb6a7b 100644 --- a/DynamicWin/WPFBinders/SKElement.cs +++ b/DynamicWin/WPFBinders/SKElement.cs @@ -30,25 +30,7 @@ public SKElement() { designMode = DesignerProperties.GetIsInDesignMode(this); - // Attempt to use OpenGL if possible. If GL fails, log exception and leave GRContext null to use CPU as fallback - try - { - var glInterface = GRGlInterface.Create(); - if (glInterface != null) - { - GrContext = GRContext.CreateGl(glInterface); - Debug.WriteLine("SKElement: Created GL GRContext successfully."); - } - else - { - Debug.WriteLine("SKElement: GRGlInterface.Create returned null - GL not available."); - } - } - catch (Exception ex) - { - Debug.WriteLine($"SKElement: Failed to create GL GRContext: {ex}"); - GrContext = null; - } + GrContext = null; } public SKSize CanvasSize { get; private set; } diff --git a/MODDING.md b/MODDING.md index 6b50844..f51eaa9 100644 --- a/MODDING.md +++ b/MODDING.md @@ -1,5 +1,13 @@ # Creating/modifying DynamicWin-Legacy with custom extensions +**We support mod extensions. You can add your own small widgets and big widgets by creating a custom extension.**
+Loading an extension from someone else is very simple. Drag the **`Mod.dll`** file you have created to the `Extensions` folder located in the `%appdata%/DynamicWin` directory. + +> [!WARNING] +> **Please never load a mod that is not tested to be safe.** + +Mods may contain malicious code that can mess up your system, so always check a mod's source code or let a trustworthy person check it for you. + To create an extension you need an IDE like [Visual Studio 2026](https://visualstudio.microsoft.com/vs/community/). - Create a new C# project of the type `Class Library`. Ensure that the target framework is **`.NET 9.0`**. - It is required to add `DynamicWin.dll` and SkiaSharp DLLs as assembly dependencies to your project. [More information regarding this through here.](https://learn.microsoft.com/en-gb/visualstudio/ide/how-to-add-or-remove-references-by-using-the-reference-manager?view=vs-2022) diff --git a/README.md b/README.md index 82ca252..7e5c8d3 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# DynamicWin Legacy +# DynamicWin-Legacy

@@ -8,20 +8,23 @@

- animated + animated

-

DynamicWin Legacy by Florian Butz is maintained by 59xa and is licenced under CC BY-SA 4.0

- -> [!NOTE] -> This repository holds the legacy code and releases for DynamicWin developed by [FlorianButz](https://github.com/FlorianButz), and is maintained by [59xa](https://github.com/59xa). Please do not report issues and missing features in this repository regarding version 2.0 as this repository only accepts version 1.0 issues. For version 2.0 releases, click [here](https://github.com/FlorianButz/DynamicWin). +

DynamicWin-Legacy developed by Florian Butz and 59xa is licenced under CC BY-SA 4.0

> [!WARNING] -> This is a legacy application that is being maintained by one developer. Do not expect most features to be fixed whatsoever. However, this does not mean groundbreaking issues and feature requests will be turned down immediately. Open an issue ticket for a new feature or an existing issue, they will be added/fixed eventually. +> As of **31st May, 2026**, DynamicWin-Legacy is feature complete, and will no longer receive further updates moving forward. The maintainer of this repository will instead shift development focus on DynamicWin's successor. Stay tuned for more details [here](https://github.com/project-vibrance). + +> [!NOTE] +> This repository holds the legacy code and releases for DynamicWin developed by [FlorianButz](https://github.com/FlorianButz), and is maintained by [59xa](https://github.com/59xa). Please do not report issues and missing features in this repository regarding V2 as this repository only accepts version 1.0 issues. For V2 releases, click [here](https://github.com/FlorianButz/DynamicWin/releases). ### What is it? -A [Dynamic Island](https://support.apple.com/de-de/guide/iphone/iph28f50d10d/ios) inspired Windows App that brings in a bunch of features like widgets or a file tray that works like a clipboard. -Similar to dynamic notches that you can find on macOS like [NotchNook](https://lo.cafe/notchnook), this application brings the concept on Windows devices to life. +A [Dynamic Island](https://support.apple.com/en-gb/guide/iphone/iph28f50d10d/ios)-inspired Windows software that brings in a bunch of features like widgets or a file tray that works like a clipboard. + +Similar to dynamic notches that you can find on macOS like [NotchNook](https://lo.cafe/notchnook), this software brings the concept on Windows devices to life. + +_DynamicWin-Legacy supports both **`x64`** and **`arm64`** releases. [Get the latest version for your Windows device here](https://github.com/59xa/DynamicWin-Legacy/releases)._ ### Implementation and build This application is developed using C# for the logic, Windows Presentation Foundation (WPF) for windowing, and [SkiaSharp](https://github.com/mono/SkiaSharp) to display the graphical interface. @@ -33,93 +36,55 @@ git pull https://github.com/59xa/DynamicWin-Legacy.git ``` ### Future plans/continued support: -- While [version 2.0](https://github.com/FlorianButz/DynamicWin) of this software has been made public, the legacy codebase will continue to exist and maintained by me until FlorianButz decides to pull the legacy support. -- This repository is no longer connected to the original repository's fork network. Please report your issues regarding V2 [here](https://github.com/FlorianButz/DynamicWin). +- DynamicWin-Legacy is now considered abandonware. Development focus has been shifted to **V3** instead, [more information here](https://github.com/project-vibrance). +- While [V2](https://github.com/FlorianButz/DynamicWin) has been made public, the legacy codebase will continue to exist for other developers and maintainers. +- This repository is not linked to the original repository's fork network. Please report your issues regarding V2 [here](https://github.com/FlorianButz/DynamicWin). - V1 (this repository) will co-exist with V2, and will not serve as a replacement but an alternative for users to use. -- Your support truly means a lot to keep maintaining DynamicWin Legacy. Keep an eye out whenever a new release comes out. - Feel free to contribute to this project as you wish. Open any issues on the issues page if you encounter any bugs. -
- -**Quick disclaimer**: The codebase is currently structured terribly and almost un-maintainable. Codebase refactoring is currently in the works starting with **`v1.4.0b`**. # Features -> [!NOTE] -> Only checkboxed features are currently available. Unimplemented features will be introduced as time passes. - DynamicWin-Legacy has a variety of features, currently including:
## Shortcuts -- [x] `Ctrl + Win` Will hide the island (or show it again). -- [ ] ~~`Shift + Win` Will open a quick search menu.~~ (Please consider using an alternative such as [Powertoys Run](https://learn.microsoft.com/en-us/windows/powertoys/run)) - -## Big Widgets -- [x] Media Playback Widget -- [x] Timer Widget -- [x] Weather Widget -- [x] Shortcuts Widget (Can be configured to open a file, e.g. Shortcut, .EXE or any other filetype.) -- [ ] Calendar Widget - -## Small Widgets -- [x] Time Display -- [x] Music Visualizer -- [x] Device Usage Detector (Indicates if camera / microphone is in use) -- [x] Power State Display (Shows battery in form of icons. If no battery is found it shows a connector icon instead) -- [x] Timer (Displaying current running timer) -- [x] CPU/GPU Usage Display - -## File Distribution & Management
+- [x] `CTRL + Win` hides the interface (or show it again). + +## Big widgets +- [x] Media playback (deprecated) +- [x] Timer +- [x] Weather +- [x] Shortcuts (can be configured to open a file, e.g. shortcut, `.exe`, and/or any other filetype) + +## Small widgets +- [x] Time display +- [x] Audio visualiser +- [x] Device usage detector (indicates if camera / microphone is in use) +- [x] Power state display (shows battery in form of icons, displays connector if battery is not found) +- [x] Timer (displays current running timer) +- [x] Resource usage display + +## File distribution & management

- animated + animated

- [x] File Tray
Files can be dragged over the island to add them to the file tray. The tray can be accessed when hovering over the island and clicking on the 'Tray' button. The files are stored until they are dragged out again. They can also be removed by selecting the file and right clicking. A context menu will popup and you can click on - **"Remove Selected Files"** or **"Remove Selected Files"** to copy the files.

-- [ ] SnapDrop API implementation
-While this feature is low-priority, please expect the introduction of this feature in the near future. - - > [!WARNING] > If you are using the file tray to import files in to an app (e.g. After Effects) make sure to not remove the files from the tray. Apps that only copy a link to the file will be lost after you remove the file from the tray. -## Spotify Integration +## Media player -

The Media Playback Widget automatically detects when an instance of the Spotify app is running (Desktop version only). It will display the current playing song name and the artist. Login to the Spotify service on the app is not required.

+

The media player uses the GSMTC interop to control and display metadata regardless of media source. Integration with other applications through sign-in or API is not required.




- -## Mod Support -**We support mod extensions. You can add your own small widgets and big widgets by creating a custom extension.**
-Loading an extension from someone else is very simple. You just need to drag the **Mod.dll** file in to the *Extensions* folder that is located in the `%appdata%/DynamicWin` directory. - -> [!WARNING] -> **Please never load a mod that is not tested to be safe.** - -Mods may contain malicious code that can mess up your system, so always check a mod's source code or let a trustworthy person check it for you. - -## Custom Themes - -

- animated -

- -> [!NOTE] -> Custom themes are not the main priority for this repository, but will remain supported for use. Visit Florian's Discord server to get access to more themes like the ones shown from above. - -You can use the built-in dark / light theme. You can also create custom themes that fit your liking by going to the `%appdata%/DynamicWin/Theme.json` file. After editing the colors you need to select the `Custom` theme option in the settings. If you already did that, you will need to go back to the settings and click on it again. Otherwise you would have to restart the app.
-This is an example of a color: -`"IslandColor": "#000000"` -
-The hex code is structured this way: `#rrggbb`. If you want to change the alpha of the color, it is **always** at the start of the code. `#aarrggbb`. - -# Known Issues -The performance might not be the best. Slowly expect codebase optimisations starting with **`v1.4.0b`**.

-The app might suddenly disappear and upon trying to reopen it a message box will tell you that only one instance of the app can run at the same time. To fix this, open task manager and find the process `DynamicWin`. Kill it and start the app again.

+## Custom themes +> [!NOTE] +> Custom themes are not the main priority for this repository, but will remain supported for use. Visit FlorianButz's [Discord server](https://discord.gg/UHFuqB9NqR) to get access to more themes. +- Read [THEMING.md](THEMING.md) to get started on decorating your interface. -Too fast interactions might confuse the animation system and will result in an empty menu. To fix this, usually moving the mouse away from the island and then over it again will fix it. +## Modding DynamicWin-Legacy (making extensions) -# Modding DynamicWin (making Extensions) -- While extension support and compatibility is not a focus for the maintainer, users are still able to make their own extensions as needed. - Read [MODDING.md](MODDING.md) for more information on how to get started. diff --git a/ReadmeFiles/IslandGif-1_Volume.gif b/ReadmeFiles/IslandGif-1_Volume.gif deleted file mode 100644 index 63b9af2..0000000 Binary files a/ReadmeFiles/IslandGif-1_Volume.gif and /dev/null differ diff --git a/ReadmeFiles/IslandGif-2_Tray.gif b/ReadmeFiles/IslandGif-2_Tray.gif deleted file mode 100644 index 0e5acb4..0000000 Binary files a/ReadmeFiles/IslandGif-2_Tray.gif and /dev/null differ diff --git a/ReadmeFiles/IslandGif-3_Spotify.gif b/ReadmeFiles/IslandGif-3_Spotify.gif deleted file mode 100644 index 1a31384..0000000 Binary files a/ReadmeFiles/IslandGif-3_Spotify.gif and /dev/null differ diff --git a/ReadmeFiles/Themes.png b/ReadmeFiles/Themes.png deleted file mode 100644 index 026b7f4..0000000 Binary files a/ReadmeFiles/Themes.png and /dev/null differ diff --git a/THEMING.md b/THEMING.md new file mode 100644 index 0000000..a0ac1a9 --- /dev/null +++ b/THEMING.md @@ -0,0 +1,15 @@ +# Theming and customising your interface + +

+ animated +

+ +> [!NOTE] +> Custom themes are not the main priority for this repository, but will remain supported for use. Visit FlorianButz's [Discord server](https://discord.gg/UHFuqB9NqR) to get access to more themes like the ones shown from above. + +You can use the built-in dark / light theme. You can also create custom themes that fit your liking by going to the `%appdata%/DynamicWin/Theme.json` file. After editing the colors you need to select the `Custom` theme option in the settings. If you already did that, you will need to go back to the settings and click on it again. Otherwise you would have to restart the app. + +This is an example of a color: +`"IslandColor": "#000000"` + +The hex code is structured this way: `#rrggbb`. If you want to change the alpha of the color, it is **always** at the start of the code. `#aarrggbb`. \ No newline at end of file diff --git a/readme-files/media.gif b/readme-files/media.gif new file mode 100644 index 0000000..505f7ee Binary files /dev/null and b/readme-files/media.gif differ diff --git a/readme-files/themes.png b/readme-files/themes.png new file mode 100644 index 0000000..fe336c6 Binary files /dev/null and b/readme-files/themes.png differ diff --git a/readme-files/tray.gif b/readme-files/tray.gif new file mode 100644 index 0000000..67c43c4 Binary files /dev/null and b/readme-files/tray.gif differ diff --git a/readme-files/volume.gif b/readme-files/volume.gif new file mode 100644 index 0000000..cc63479 Binary files /dev/null and b/readme-files/volume.gif differ