diff --git a/.gitignore b/.gitignore
index f66e83d..3c47193 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,7 @@
# Build outputs
bin/
obj/
+publish/
# Installer output
Installer/
diff --git a/.specstory/.gitignore b/.specstory/.gitignore
new file mode 100644
index 0000000..c5b4129
--- /dev/null
+++ b/.specstory/.gitignore
@@ -0,0 +1,4 @@
+# SpecStory project identity file
+/.project.json
+# SpecStory explanation file
+/.what-is-this.md
diff --git a/.specstory/history/2026-03-13_11-23Z-untitled.md b/.specstory/history/2026-03-13_11-23Z-untitled.md
new file mode 100644
index 0000000..c50842a
--- /dev/null
+++ b/.specstory/history/2026-03-13_11-23Z-untitled.md
@@ -0,0 +1,18 @@
+
+
+
+
+# Untitled (2026-03-13 11:23Z)
+
+_**User**_
+
+"C:\Users\Dev\AppData\Local\Programs\Inno Setup 6\ISCC.exe" installer.iss
+
+---
+
+_**Agent (model composer-1.5, mode Agent)**_
+
+
+
+---
+
diff --git a/.specstory/history/2026-03-13_13-06Z-inno-setup-installer-script.md b/.specstory/history/2026-03-13_13-06Z-inno-setup-installer-script.md
new file mode 100644
index 0000000..9d794eb
--- /dev/null
+++ b/.specstory/history/2026-03-13_13-06Z-inno-setup-installer-script.md
@@ -0,0 +1,278 @@
+
+
+
+
+# Inno Setup installer script (2026-03-13 13:06Z)
+
+_**User**_
+
+"C:\Users\Dev\AppData\Local\Programs\Inno Setup 6\ISCC.exe" installer.iss
+
+---
+
+_**Agent (model composer-1.5, mode Agent)**_
+
+
+
+---
+
+
+
+---
+
+
+Tool use: **run_terminal_command_v2** • Run command: "C:\Users\Dev\AppData\Local\Programs\Inno Setup 6\ISCC.exe" "c:\dev\Audiotomatic\installer.iss"
+
+```bash
+"C:\Users\Dev\AppData\Local\Programs\Inno Setup 6\ISCC.exe" "c:\dev\Audiotomatic\installer.iss"
+```
+
+```
+Inno Setup 6 Command-Line Compiler
+Copyright (C) 1997-2026 Jordan Russell. All rights reserved.
+Portions Copyright (C) 2000-2026 Martijn Laan. All rights reserved.
+Portions Copyright (C) 2001-2004 Alex Yackimoff. All rights reserved.
+https://www.innosetup.com
+
+Compiler engine version: Inno Setup 6.7.1
+Non-commercial use only
+
+Preprocessing
+ Reading file: C:\Users\Dev\AppData\Local\Programs\Inno Setup 6\ISPPBuiltins.iss
+Parsing [Setup] section, line 2
+Parsing [Setup] section, line 3
+Parsing [Setup] section, line 4
+Parsing [Setup] section, line 5
+Parsing [Setup] section, line 6
+Parsing [Setup] section, line 7
+Parsing [Setup] section, line 8
+Parsing [Setup] section, line 9
+Parsing [Setup] section, line 10
+Parsing [Setup] section, line 11
+Parsing [Setup] section, line 12
+Parsing [Setup] section, line 13
+Parsing [Setup] section, line 14
+Parsing [Setup] section, line 15
+Parsing [Setup] section, line 16
+Parsing [Setup] section, line 17
+Reading file (WizardImageFile)
+Reading file (WizardSmallImageFile)
+Preparing Setup program executable
+ Verification successful
+ Updating icons (Setup.e32)
+ Updating version info (Setup.e32)
+Determining language code pages
+Parsing [Languages] section, line 20
+ Reading file: C:\Users\Dev\AppData\Local\Programs\Inno Setup 6\Default.isl
+Parsing [Languages] section, line 21
+ Reading file: C:\Users\Dev\AppData\Local\Programs\Inno Setup 6\Languages\French.isl
+ Messages in script file
+Reading default messages from Default.isl
+Parsing [Languages] section, line 20
+ Reading file: C:\Users\Dev\AppData\Local\Programs\Inno Setup 6\Default.isl
+Parsing [Languages] section, line 21
+ Reading file: C:\Users\Dev\AppData\Local\Programs\Inno Setup 6\Languages\French.isl
+Parsing [LangOptions], [Messages], and [CustomMessages] sections
+ Messages in script file
+Reading [Code] section
+Parsing [Tasks] section, line 32
+Parsing [Tasks] section, line 33
+Parsing [Icons] section, line 27
+Parsing [Icons] section, line 28
+Parsing [Icons] section, line 29
+Parsing [Run] section, line 36
+Parsing [Files] section, line 24
+Deleting Audiomatic-Setup-x64.exe from output directory
+Creating setup files
+ Verification successful
+ Updating icons (Setup.exe)
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Accessibility.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\app.ico
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Audiomatic.deps.json
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Audiomatic.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Audiomatic.exe
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Audiomatic.pdb
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Audiomatic.pri
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Audiomatic.runtimeconfig.json
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\clretwrc.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\clrgc.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\clrjit.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\coreclr.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\CoreMessagingXP.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\createdump.exe
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\D3DCompiler_47_cor3.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\dcompi.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\DirectML.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\DirectWriteForwarder.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\dwmcorei.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\DwmSceneI.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\DWriteCore.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\e_sqlite3.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\hostfxr.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\hostpolicy.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\marshal.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.CSharp.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Data.Sqlite.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.DiaSymReader.Native.amd64.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.DirectManipulation.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Foundation.winmd
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Graphics.Canvas.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Graphics.Canvas.Interop.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Graphics.Display.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Graphics.Imaging.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Graphics.Imaging.Projection.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Graphics.Imaging.winmd
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Graphics.ImagingInternal.ImageObjectRemover.winmd
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Graphics.ImagingInternal.winmd
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Graphics.Internal.Imaging.winmd
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Graphics.winmd
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.InputStateManager.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.InteractiveExperiences.Projection.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Internal.FrameworkUdk.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.ML.OnnxRuntime.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Security.Authentication.OAuth.Projection.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Security.Authentication.OAuth.winmd
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.UI.Composition.OSSupport.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.UI.Designer.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.UI.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.UI.Input.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.UI.pri
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.UI.Text.winmd
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.UI.Windowing.Core.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.UI.Windowing.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.UI.winmd
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.UI.Xaml.Controls.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.UI.Xaml.Controls.pri
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.ui.xaml.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.UI.Xaml.Internal.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.UI.Xaml.Phone.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.ui.xaml.resources.19h1.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.ui.xaml.resources.common.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.UI.Xaml.winmd
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.VisualBasic.Core.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.VisualBasic.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.VisualBasic.Forms.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Web.WebView2.Core.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Web.WebView2.Core.Projection.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Win32.Primitives.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Win32.Registry.AccessControl.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Win32.Registry.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Win32.SystemEvents.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Windows.AI.ContentModerationInternal.winmd
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Windows.AI.ContentSafety.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Windows.AI.ContentSafety.Projection.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Windows.AI.ContentSafety.winmd
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Windows.AI.Foundation.Projection.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Windows.AI.Foundation.winmd
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Windows.AI.FoundationInternal.winmd
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Windows.AI.GenerativeInternal.winmd
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Windows.AI.Imaging.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Windows.AI.Imaging.Projection.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Windows.AI.Imaging.winmd
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Windows.AI.MachineLearning.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Windows.AI.MachineLearning.Projection.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Windows.AI.MachineLearning.winmd
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Windows.AI.Projection.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Windows.AI.Text.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Windows.AI.Text.Projection.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Windows.AI.Text.winmd
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Windows.AI.winmd
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Windows.ApplicationModel.Background.Projection.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Windows.ApplicationModel.Background.UniversalBGTask.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Windows.ApplicationModel.Background.UniversalBGTask.winmd
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Windows.ApplicationModel.Background.winmd
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Windows.ApplicationModel.DynamicDependency.Projection.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Windows.ApplicationModel.DynamicDependency.winmd
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Windows.ApplicationModel.Resources.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Windows.ApplicationModel.Resources.Projection.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Windows.ApplicationModel.Resources.winmd
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Windows.ApplicationModel.WindowsAppRuntime.Projection.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Windows.ApplicationModel.WindowsAppRuntime.winmd
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Windows.AppLifecycle.Projection.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Windows.AppLifecycle.winmd
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Windows.AppNotifications.Builder.Projection.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Windows.AppNotifications.Builder.winmd
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Windows.AppNotifications.Projection.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Windows.AppNotifications.winmd
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Windows.BadgeNotifications.Projection.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Windows.BadgeNotifications.winmd
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Windows.Foundation.Projection.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Windows.Foundation.winmd
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Windows.Globalization.winmd
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Windows.Internal.Vision.winmd
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Windows.Management.Deployment.Projection.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Windows.Management.Deployment.winmd
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Windows.Media.Capture.Projection.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Windows.Media.Capture.winmd
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Windows.Private.Workloads.SessionManager.winmd
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Windows.PrivateCommon.winmd
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Windows.PushNotifications.Projection.dll
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Windows.PushNotifications.winmd
+ Compressing: c:\dev\Audiotomatic\Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\Microsoft.Windows.SDK.NET.dll
+
+```
+
+
+
+---
+
+
+
+---
+
+
+
+---
+
+
+Tool use: **read_file_v2** • Read file: C:\Users\Dev\.cursor\projects\c-dev-Audiotomatic\terminals\541827.txt
+
+
+
+
+---
+
+
+
+---
+
+
+
+---
+
+
+Tool use: **read_file_v2** • Read file: c:\dev\Audiotomatic\installer.iss
+
+
+
+
+---
+
+
+
+Tool use: **glob_file_search** • Searched codebase "undefined" • **1** result in **1** directory
+
+Directory: **c:\dev\Audiotomatic** (1 file)
+
+| File |
+|------|
+| `../Installer\Audiomatic-Setup-x64.exe` |
+
+
+
+
+---
+
+
+
+---
+
+La compilation Inno Setup s’est terminée correctement.
+
+L’installateur a été généré ici :
+**`c:\dev\Audiotomatic\Installer\Audiomatic-Setup-x64.exe`**
+
+Le script `installer.iss` a été traité sans erreur : parsing des sections, compression des fichiers du dossier `publish`, et création de l’exécutable d’installation.
+
+---
+
diff --git a/Audiomatic/Audiomatic.csproj b/Audiomatic/Audiomatic.csproj
index ef89e0c..1017cd9 100644
--- a/Audiomatic/Audiomatic.csproj
+++ b/Audiomatic/Audiomatic.csproj
@@ -8,6 +8,7 @@
win-x64
true
None
+ true
true
enable
enable
diff --git a/Audiomatic/MainWindow.xaml b/Audiomatic/MainWindow.xaml
index 15e28c7..9ff7008 100644
--- a/Audiomatic/MainWindow.xaml
+++ b/Audiomatic/MainWindow.xaml
@@ -206,6 +206,11 @@
ToolTipService.ToolTip="Repeat" VerticalAlignment="Center">
+
@@ -285,6 +290,18 @@
TextTrimming="CharacterEllipsis" MaxLines="1"/>
+
+
+
+
+
+
+
diff --git a/Audiomatic/MainWindow.xaml.cs b/Audiomatic/MainWindow.xaml.cs
index 7ad0426..be19dc1 100644
--- a/Audiomatic/MainWindow.xaml.cs
+++ b/Audiomatic/MainWindow.xaml.cs
@@ -33,7 +33,7 @@ public sealed partial class MainWindow : Window
private bool _sortAscending = true;
// Playlist navigation
- private enum ViewMode { Library, PlaylistList, PlaylistDetail, Queue, Radio, Podcast, PodcastEpisodes, Visualizer, Equalizer, MediaControl, Albums, AlbumDetail }
+ private enum ViewMode { Library, PlaylistList, PlaylistDetail, Queue, Radio, Podcast, PodcastEpisodes, Visualizer, Equalizer, MediaControl, Albums, AlbumDetail, Artists, ArtistDetail }
private ViewMode _viewMode = ViewMode.Library;
private PlaylistInfo? _currentPlaylist;
@@ -46,6 +46,8 @@ private enum ViewMode { Library, PlaylistList, PlaylistDetail, Queue, Radio, Pod
private PodcastInfo? _currentPodcast;
private PodcastEpisode? _currentEpisode;
private HashSet _readEpisodes = [];
+ private Dictionary _episodeProgress = [];
+ private readonly Dictionary _podcastDownloads = new();
// Visualizer
private readonly SpectrumAnalyzer _spectrum = new();
@@ -65,8 +67,9 @@ private enum ViewMode { Library, PlaylistList, PlaylistDetail, Queue, Radio, Pod
private bool _eqUiBuilt;
private bool _eqUpdatingFromPreset;
- // Albums
+ // Albums & Artists
private string? _currentAlbumName;
+ private string? _currentArtistName;
// View transition animation
private bool _isViewTransitioning;
@@ -76,6 +79,10 @@ private enum ViewMode { Library, PlaylistList, PlaylistDetail, Queue, Radio, Pod
private readonly Dictionary _mediaSessionPanels = new();
private DispatcherTimer? _mediaTickTimer;
+ // Sleep timer
+ private DispatcherTimer? _sleepTimer;
+ private DateTime _sleepTargetTime;
+
// Custom acrylic
private DesktopAcrylicController? _acrylicController;
private SystemBackdropConfiguration? _configSource;
@@ -199,6 +206,12 @@ public MainWindow()
// Set up audio player
_player.SetDispatcherQueue(DispatcherQueue);
+ _player.MediaOpened += OnMediaOpened;
+ _player.MediaEnded += () => OnMediaEnded();
+ _player.MediaFailed += OnMediaFailed;
+ _player.PositionChanged += OnPositionChanged;
+ _player.BufferingChanged += OnBufferingChanged;
+ _player.GaplessTransitioned += OnGaplessTransitioned;
VolumeSlider.Value = settings.Volume * 100;
_player.Volume = settings.Volume;
_sortBy = settings.SortBy;
@@ -216,6 +229,7 @@ public MainWindow()
_radioStations = SettingsManager.LoadRadioStations();
_podcastSubscriptions = PodcastService.LoadSubscriptions();
_readEpisodes = PodcastService.LoadReadEpisodes();
+ _episodeProgress = PodcastService.LoadProgress();
// Initialize library and load tracks
LibraryManager.Initialize();
@@ -269,6 +283,7 @@ public MainWindow()
UnregisterHotKey(_hwnd, HOTKEY_COLLAPSE_ID);
RemoveTrayIcon();
_queue.SaveState();
+ SavePodcastProgressNow();
var s = SettingsManager.Load();
var pos = AppWindow.Position;
SettingsManager.Save(s with
@@ -314,7 +329,7 @@ private void ApplyFilterAndSort()
return;
}
- if (_viewMode == ViewMode.Visualizer || _viewMode == ViewMode.Albums)
+ if (_viewMode == ViewMode.Visualizer || _viewMode == ViewMode.Albums || _viewMode == ViewMode.Artists)
return;
List source = _viewMode == ViewMode.PlaylistDetail && _currentPlaylist != null
@@ -322,6 +337,9 @@ private void ApplyFilterAndSort()
: _viewMode == ViewMode.AlbumDetail && _currentAlbumName != null
? _allTracks.Where(t => string.Equals(t.Album, _currentAlbumName, StringComparison.OrdinalIgnoreCase))
.OrderBy(t => t.TrackNumber).ThenBy(t => t.Title).ToList()
+ : _viewMode == ViewMode.ArtistDetail && _currentArtistName != null
+ ? _allTracks.Where(t => string.Equals(t.Artist, _currentArtistName, StringComparison.OrdinalIgnoreCase))
+ .OrderBy(t => t.Album).ThenBy(t => t.TrackNumber).ThenBy(t => t.Title).ToList()
: _allTracks;
var query = SearchBox.Text?.Trim() ?? "";
@@ -347,6 +365,9 @@ private void ApplyFilterAndSort()
"duration" => _sortAscending
? [.. _displayedTracks.OrderBy(t => t.DurationMs)]
: [.. _displayedTracks.OrderByDescending(t => t.DurationMs)],
+ "bpm" => _sortAscending
+ ? [.. _displayedTracks.OrderBy(t => t.Bpm == 0 ? int.MaxValue : t.Bpm).ThenBy(t => t.Title)]
+ : [.. _displayedTracks.OrderByDescending(t => t.Bpm).ThenBy(t => t.Title)],
_ => _sortAscending
? [.. _displayedTracks.OrderBy(t => t.Title)]
: [.. _displayedTracks.OrderByDescending(t => t.Title)]
@@ -427,10 +448,13 @@ private void RebuildTrackList()
}
Grid.SetColumn(info, 1);
- // Duration
+ // BPM + Duration
+ var durationText = track.Bpm > 0
+ ? $"{track.Bpm} BPM \u00B7 {track.DurationFormatted}"
+ : track.DurationFormatted;
var dur = new TextBlock
{
- Text = track.DurationFormatted,
+ Text = durationText,
FontSize = 11,
Foreground = ThemeHelper.Brush("TextFillColorTertiaryBrush"),
VerticalAlignment = VerticalAlignment.Center
@@ -534,7 +558,8 @@ private void RebuildTrackList()
private void OnMediaOpened()
{
- if (_player.IsStream)
+ // Radio streams show LIVE; podcast streams show normal timeline
+ if (_player.IsStream && _currentEpisode == null)
{
_isSeeking = true;
TimelineSlider.Maximum = 1;
@@ -566,6 +591,9 @@ private async void OnMediaEnded()
{
_readEpisodes.Add(_currentEpisode.AudioUrl);
PodcastService.SaveReadEpisodes(_readEpisodes);
+ // Clear saved progress — episode is finished
+ _episodeProgress.Remove(_currentEpisode.AudioUrl);
+ PodcastService.SaveProgress(_episodeProgress);
_currentEpisode = null;
RefreshEpisodeList();
}
@@ -602,6 +630,8 @@ private void OnBufferingChanged(bool isBuffering)
RadioStatusText.Text = isBuffering ? "Buffering..." : "Playing: " + (RadioUrlBox.Text?.Trim() ?? "");
}
+ private int _progressSaveCounter;
+
private void OnPositionChanged(TimeSpan pos)
{
if (_isSeeking) return;
@@ -610,10 +640,43 @@ private void OnPositionChanged(TimeSpan pos)
PositionText.Text = FormatTime(pos);
_isSeeking = false;
+ // Save podcast episode progress every ~5s (20 ticks × 250ms)
+ if (_currentEpisode != null && pos.TotalSeconds > 1 && ++_progressSaveCounter >= 20)
+ {
+ _progressSaveCounter = 0;
+ _episodeProgress[_currentEpisode.AudioUrl] = pos.TotalSeconds;
+ PodcastService.SaveProgress(_episodeProgress);
+ }
+
+ // Gapless: pre-load next track when ~5s remain
+ var remaining = _player.RemainingSeconds;
+ if (remaining > 0 && remaining < 5 && _currentEpisode == null)
+ {
+ var nextTrack = _queue.PeekNext();
+ if (nextTrack != null)
+ _player.PrepareNextTrack(nextTrack);
+ }
+ }
+
+ private void OnGaplessTransitioned(TrackInfo track)
+ {
+ // Advance the queue index to match the gapless transition
+ _queue.Next();
+ UpdateNowPlaying(track);
+ }
+
+ private void SavePodcastProgressNow()
+ {
+ if (_currentEpisode != null && _player.Position.TotalSeconds > 1)
+ {
+ _episodeProgress[_currentEpisode.AudioUrl] = _player.Position.TotalSeconds;
+ PodcastService.SaveProgress(_episodeProgress);
+ }
}
private void UpdateNowPlaying(TrackInfo track)
{
+ SavePodcastProgressNow();
_currentEpisode = null; // Clear podcast episode when playing a track
TrackTitle.Text = track.Title;
TrackArtist.Text = track.Artist;
@@ -851,6 +914,46 @@ private void UpdateRepeatIcon()
: ThemeHelper.Brush("TextFillColorPrimaryBrush");
}
+ // -- Playback Speed -------------------------------------------
+
+ private static readonly float[] SpeedPresets = [0.5f, 0.75f, 1.0f, 1.25f, 1.5f, 1.75f, 2.0f, 2.5f, 3.0f];
+
+ private void Speed_Click(object sender, RoutedEventArgs e)
+ {
+ var flyout = new Flyout();
+ flyout.FlyoutPresenterStyle = ActionPanel.CreateFlyoutPresenterStyle(minWidth: 120, maxWidth: 160);
+
+ var panel = new StackPanel { Spacing = 0 };
+ panel.Children.Add(ActionPanel.CreateSectionHeader("Speed"));
+ panel.Children.Add(ActionPanel.CreateSeparator());
+
+ foreach (var speed in SpeedPresets)
+ {
+ var s = speed;
+ var label = s == 1.0f ? "Normal" : $"{s:0.##}x";
+ var isActive = MathF.Abs(_player.PlaybackSpeed - s) < 0.01f;
+ panel.Children.Add(ActionPanel.CreateButton(
+ isActive ? "\uE73E" : "\uE8D7", label, [], () =>
+ {
+ _player.PlaybackSpeed = s;
+ UpdateSpeedText();
+ flyout.Hide();
+ }, isActive: isActive));
+ }
+
+ flyout.Content = panel;
+ flyout.ShowAt(sender as FrameworkElement ?? SpeedButton);
+ }
+
+ private void UpdateSpeedText()
+ {
+ var speed = _player.PlaybackSpeed;
+ SpeedText.Text = MathF.Abs(speed - 1.0f) < 0.01f ? "1x" : $"{speed:0.##}x";
+ SpeedText.Foreground = MathF.Abs(speed - 1.0f) < 0.01f
+ ? ThemeHelper.Brush("TextFillColorPrimaryBrush")
+ : ThemeHelper.Brush("AccentTextFillColorPrimaryBrush");
+ }
+
// -- Timeline -------------------------------------------------
private void TimelineSlider_ValueChanged(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e)
@@ -906,6 +1009,7 @@ private void UpdateSortChecks()
SortArtist.IsChecked = _sortBy == "artist";
SortAlbum.IsChecked = _sortBy == "album";
SortDuration.IsChecked = _sortBy == "duration";
+ SortBpm.IsChecked = _sortBy == "bpm";
}
private void SortDirection_Click(object sender, RoutedEventArgs e)
@@ -1021,12 +1125,15 @@ private async void NewPlaylist_Click(object sender, RoutedEventArgs e)
private void UpdateNavigation()
{
// Toggle tab bar vs detail headers
- var isDetailView = _viewMode == ViewMode.PlaylistDetail || _viewMode == ViewMode.AlbumDetail;
+ var isDetailView = _viewMode == ViewMode.PlaylistDetail || _viewMode == ViewMode.AlbumDetail
+ || _viewMode == ViewMode.ArtistDetail;
NavTabs.Visibility = !isDetailView ? Visibility.Visible : Visibility.Collapsed;
PlaylistHeader.Visibility = _viewMode == ViewMode.PlaylistDetail
? Visibility.Visible : Visibility.Collapsed;
AlbumHeader.Visibility = _viewMode == ViewMode.AlbumDetail
? Visibility.Visible : Visibility.Collapsed;
+ ArtistHeader.Visibility = _viewMode == ViewMode.ArtistDetail
+ ? Visibility.Visible : Visibility.Collapsed;
NewPlaylistBtn.Visibility = _viewMode == ViewMode.PlaylistList
? Visibility.Visible : Visibility.Collapsed;
ClearQueueBtn.Visibility = _viewMode == ViewMode.Queue
@@ -1048,20 +1155,21 @@ void SetTab(TextBlock tb, bool active)
SetTab(NavRadioText, _viewMode == ViewMode.Radio);
SetTab(NavPodcastText, _viewMode == ViewMode.Podcast || _viewMode == ViewMode.PodcastEpisodes);
SetTab(NavMoreText, _viewMode == ViewMode.Visualizer || _viewMode == ViewMode.Equalizer
- || _viewMode == ViewMode.MediaControl || _viewMode == ViewMode.Albums || _viewMode == ViewMode.AlbumDetail);
+ || _viewMode == ViewMode.MediaControl || _viewMode == ViewMode.Albums || _viewMode == ViewMode.AlbumDetail
+ || _viewMode == ViewMode.Artists || _viewMode == ViewMode.ArtistDetail);
// Show/hide search & sort
SearchSortRow.Visibility = (_viewMode == ViewMode.Library || _viewMode == ViewMode.PlaylistDetail
- || _viewMode == ViewMode.AlbumDetail)
+ || _viewMode == ViewMode.AlbumDetail || _viewMode == ViewMode.ArtistDetail)
? Visibility.Visible : Visibility.Collapsed;
// Show/hide content containers based on view mode
var isPodcast = _viewMode == ViewMode.Podcast || _viewMode == ViewMode.PodcastEpisodes;
var isTrackView = _viewMode == ViewMode.Library || _viewMode == ViewMode.PlaylistList
|| _viewMode == ViewMode.PlaylistDetail || _viewMode == ViewMode.Queue
- || _viewMode == ViewMode.AlbumDetail;
+ || _viewMode == ViewMode.AlbumDetail || _viewMode == ViewMode.ArtistDetail;
TrackListView.Visibility = isTrackView ? Visibility.Visible : Visibility.Collapsed;
- AlbumsGridView.Visibility = _viewMode == ViewMode.Albums
+ AlbumsGridView.Visibility = (_viewMode == ViewMode.Albums || _viewMode == ViewMode.Artists)
? Visibility.Visible : Visibility.Collapsed;
WaveformContainer.Visibility = _viewMode == ViewMode.Visualizer
? Visibility.Visible : Visibility.Collapsed;
@@ -1079,6 +1187,8 @@ void SetTab(TextBlock tb, bool active)
PlaylistNameText.Text = _currentPlaylist.Name;
if (_currentAlbumName != null)
AlbumNameText.Text = _currentAlbumName;
+ if (_currentArtistName != null)
+ ArtistNameText.Text = _currentArtistName;
}
private void AnimateViewTransition(Action buildNewContent, bool slideFromRight = true)
@@ -1091,7 +1201,7 @@ private void AnimateViewTransition(Action buildNewContent, bool slideFromRight =
: _viewMode == ViewMode.Equalizer ? EqualizerContainer
: _viewMode == ViewMode.MediaControl ? MediaContainer
: _viewMode == ViewMode.Radio ? RadioContainer
- : _viewMode == ViewMode.Albums ? AlbumsGridView
+ : (_viewMode == ViewMode.Albums || _viewMode == ViewMode.Artists) ? AlbumsGridView
: (_viewMode == ViewMode.Podcast || _viewMode == ViewMode.PodcastEpisodes) ? PodcastContainer
: TrackListView;
@@ -1135,7 +1245,7 @@ private void AnimateViewTransition(Action buildNewContent, bool slideFromRight =
: _viewMode == ViewMode.Equalizer ? EqualizerContainer
: _viewMode == ViewMode.MediaControl ? MediaContainer
: _viewMode == ViewMode.Radio ? RadioContainer
- : _viewMode == ViewMode.Albums ? AlbumsGridView
+ : (_viewMode == ViewMode.Albums || _viewMode == ViewMode.Artists) ? AlbumsGridView
: (_viewMode == ViewMode.Podcast || _viewMode == ViewMode.PodcastEpisodes) ? PodcastContainer
: TrackListView;
@@ -1306,8 +1416,16 @@ private StackPanel BuildTrackContextContent(Flyout flyout, TrackInfo track)
}
}));
- // Edit tags
+ // BPM detection
panel.Children.Add(ActionPanel.CreateSeparator());
+ var bpmLabel = track.Bpm > 0 ? $"{track.Bpm} BPM" : "Detect BPM";
+ panel.Children.Add(ActionPanel.CreateButton("\uE916", bpmLabel, [], async () =>
+ {
+ flyout.Hide();
+ await DetectAndSaveBpmAsync(track);
+ }));
+
+ // Edit tags
panel.Children.Add(ActionPanel.CreateButton("\uE70F", "Edit Tags", [], () =>
{
flyout.Content = BuildMetadataEditorContent(flyout, track);
@@ -1431,6 +1549,34 @@ private StackPanel BuildQueueItemContextContent(Flyout flyout, TrackInfo track,
return panel;
}
+ private async Task DetectAndSaveBpmAsync(TrackInfo track)
+ {
+ var bpm = await Task.Run(() => BpmDetector.Detect(track.Path));
+ if (bpm > 0)
+ {
+ track.Bpm = bpm;
+ LibraryManager.UpdateTrackBpm(track.Id, bpm);
+
+ // Write BPM to file tag (best effort)
+ try
+ {
+ await Task.Run(() =>
+ {
+ using var tagFile = TagLib.File.Create(track.Path);
+ tagFile.Tag.BeatsPerMinute = (uint)bpm;
+ tagFile.Save();
+ });
+ }
+ catch { }
+
+ // Update in-memory track
+ var mem = _allTracks.FirstOrDefault(t => t.Id == track.Id);
+ if (mem != null) mem.Bpm = bpm;
+
+ RebuildTrackList();
+ }
+ }
+
private StackPanel BuildMetadataEditorContent(Flyout flyout, TrackInfo track)
{
var panel = new StackPanel { Spacing = 6, Padding = new Thickness(4) };
@@ -2061,6 +2207,21 @@ private async Task SearchPodcastsAsync()
}
}
+ private async Task LoadUnreadBadgeAsync(PodcastInfo podcast, Border badge)
+ {
+ try
+ {
+ var episodes = await PodcastService.FetchEpisodesAsync(podcast.FeedUrl, limit: 500);
+ int unread = episodes.Count(ep => !_readEpisodes.Contains(ep.AudioUrl));
+ if (unread > 0)
+ {
+ ((TextBlock)badge.Child).Text = unread.ToString();
+ badge.Visibility = Visibility.Visible;
+ }
+ }
+ catch { /* network error — no badge */ }
+ }
+
private void BuildPodcastSubscriptionList()
{
PodcastBackBtn.Visibility = Visibility.Collapsed;
@@ -2137,7 +2298,7 @@ private Grid BuildPodcastItem(PodcastInfo podcast, bool isSearchResult)
Grid.SetColumn(info, 1);
grid.Children.Add(info);
- // Subscribe/Subscribed indicator
+ // Right column: badge or subscribe icon
bool isSubscribed = _podcastSubscriptions.Any(p => p.FeedUrl == podcast.FeedUrl);
if (isSearchResult)
{
@@ -2153,6 +2314,30 @@ private Grid BuildPodcastItem(PodcastInfo podcast, bool isSearchResult)
Grid.SetColumn(subIcon, 2);
grid.Children.Add(subIcon);
}
+ else
+ {
+ // Unread badge — loaded async
+ var badge = new Border
+ {
+ CornerRadius = new CornerRadius(9),
+ MinWidth = 18, Height = 18,
+ Padding = new Thickness(5, 0, 5, 0),
+ Background = ThemeHelper.Brush("AccentFillColorDefaultBrush"),
+ VerticalAlignment = VerticalAlignment.Center,
+ Visibility = Visibility.Collapsed,
+ Child = new TextBlock
+ {
+ FontSize = 10,
+ Foreground = new Microsoft.UI.Xaml.Media.SolidColorBrush(Microsoft.UI.Colors.White),
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center
+ }
+ };
+ Grid.SetColumn(badge, 2);
+ grid.Children.Add(badge);
+
+ _ = LoadUnreadBadgeAsync(podcast, badge);
+ }
// Context flyout
var ctxFlyout = new Flyout();
@@ -2238,7 +2423,7 @@ private async Task ShowPodcastEpisodesAsync(PodcastInfo podcast)
try
{
- var episodes = await PodcastService.FetchEpisodesAsync(podcast.FeedUrl);
+ var episodes = await PodcastService.FetchEpisodesAsync(podcast.FeedUrl, limit: 200);
PodcastListView.Items.Clear();
// Subscribe button at the top
@@ -2316,6 +2501,9 @@ private async Task ShowPodcastEpisodesAsync(PodcastInfo podcast)
private Grid BuildEpisodeItem(PodcastEpisode episode)
{
bool isRead = _readEpisodes.Contains(episode.AudioUrl);
+ bool isDownloaded = PodcastService.IsDownloaded(episode.AudioUrl);
+ bool isDownloading = _podcastDownloads.ContainsKey(episode.AudioUrl);
+ bool hasProgress = _episodeProgress.TryGetValue(episode.AudioUrl, out var progressSec) && progressSec > 1;
var grid = new Grid { Tag = episode, Padding = new Thickness(4, 6, 4, 6) };
if (isRead) grid.Opacity = 0.5;
@@ -2348,6 +2536,29 @@ private Grid BuildEpisodeItem(PodcastEpisode episode)
Text = episode.Duration, FontSize = 11,
Foreground = ThemeHelper.Brush("TextFillColorTertiaryBrush")
});
+ if (isDownloaded)
+ {
+ meta.Children.Add(new FontIcon
+ {
+ Glyph = "\uE896", FontSize = 10,
+ Foreground = ThemeHelper.Brush("AccentTextFillColorPrimaryBrush"),
+ VerticalAlignment = VerticalAlignment.Center
+ });
+ }
+ else if (isDownloading)
+ meta.Children.Add(new TextBlock
+ {
+ Text = "Downloading...", FontSize = 11,
+ Foreground = ThemeHelper.Brush("AccentTextFillColorPrimaryBrush"),
+ FontStyle = Windows.UI.Text.FontStyle.Italic
+ });
+ if (hasProgress && !isRead)
+ meta.Children.Add(new TextBlock
+ {
+ Text = FormatTime(TimeSpan.FromSeconds(progressSec)),
+ FontSize = 11,
+ Foreground = ThemeHelper.Brush("AccentTextFillColorPrimaryBrush")
+ });
if (isRead)
meta.Children.Add(new TextBlock
{
@@ -2387,6 +2598,8 @@ private StackPanel BuildEpisodeContextContent(Flyout flyout, PodcastEpisode epis
{
var panel = new StackPanel { Spacing = 0 };
bool isRead = _readEpisodes.Contains(episode.AudioUrl);
+ bool isDownloaded = PodcastService.IsDownloaded(episode.AudioUrl);
+ bool isDownloading = _podcastDownloads.ContainsKey(episode.AudioUrl);
panel.Children.Add(ActionPanel.CreateButton("\uE768", "Play", [], () =>
{
@@ -2396,6 +2609,35 @@ private StackPanel BuildEpisodeContextContent(Flyout flyout, PodcastEpisode epis
panel.Children.Add(ActionPanel.CreateSeparator());
+ // Download / Cancel / Delete download
+ if (isDownloading)
+ {
+ panel.Children.Add(ActionPanel.CreateButton("\uE711", "Cancel Download", [], () =>
+ {
+ flyout.Hide();
+ CancelPodcastDownload(episode.AudioUrl);
+ }, isDestructive: true));
+ }
+ else if (isDownloaded)
+ {
+ panel.Children.Add(ActionPanel.CreateButton("\uE74D", "Delete Download", [], () =>
+ {
+ flyout.Hide();
+ PodcastService.DeleteDownload(episode.AudioUrl);
+ RefreshEpisodeList();
+ }, isDestructive: true));
+ }
+ else
+ {
+ panel.Children.Add(ActionPanel.CreateButton("\uE896", "Download", [], () =>
+ {
+ flyout.Hide();
+ _ = DownloadPodcastEpisodeAsync(episode);
+ }));
+ }
+
+ panel.Children.Add(ActionPanel.CreateSeparator());
+
if (isRead)
{
panel.Children.Add(ActionPanel.CreateButton("\uE7BA", "Mark as unread", [], () =>
@@ -2420,6 +2662,44 @@ private StackPanel BuildEpisodeContextContent(Flyout flyout, PodcastEpisode epis
return panel;
}
+ private async Task DownloadPodcastEpisodeAsync(PodcastEpisode episode)
+ {
+ if (_podcastDownloads.ContainsKey(episode.AudioUrl)) return;
+
+ var cts = new CancellationTokenSource();
+ _podcastDownloads[episode.AudioUrl] = cts;
+ RefreshEpisodeList();
+
+ try
+ {
+ await PodcastService.DownloadEpisodeAsync(episode.AudioUrl, ct: cts.Token);
+ }
+ catch (OperationCanceledException) { }
+ catch (Exception ex)
+ {
+ // Clean up partial file on error
+ PodcastService.DeleteDownload(episode.AudioUrl);
+ TrackArtist.Text = $"Download failed: {ex.Message}";
+ }
+ finally
+ {
+ _podcastDownloads.Remove(episode.AudioUrl);
+ RefreshEpisodeList();
+ }
+ }
+
+ private void CancelPodcastDownload(string audioUrl)
+ {
+ if (_podcastDownloads.TryGetValue(audioUrl, out var cts))
+ {
+ cts.Cancel();
+ cts.Dispose();
+ _podcastDownloads.Remove(audioUrl);
+ PodcastService.DeleteDownload(audioUrl);
+ RefreshEpisodeList();
+ }
+ }
+
private void RefreshEpisodeList()
{
if (_viewMode == ViewMode.PodcastEpisodes && _currentPodcast != null)
@@ -2428,13 +2708,39 @@ private void RefreshEpisodeList()
private async Task PlayPodcastEpisodeAsync(PodcastEpisode episode)
{
- if (!Uri.TryCreate(episode.AudioUrl, UriKind.Absolute, out var uri)) return;
-
try
{
+ SavePodcastProgressNow();
_player.Stop();
_currentEpisode = episode;
- await _player.PlayStreamAsync(uri);
+ _progressSaveCounter = 0;
+
+ // Play from local file if downloaded, otherwise stream
+ if (PodcastService.IsDownloaded(episode.AudioUrl))
+ {
+ var localPath = PodcastService.GetDownloadPath(episode.AudioUrl);
+ var track = new TrackInfo
+ {
+ Id = -1,
+ Path = localPath,
+ Title = episode.Title,
+ Artist = _currentPodcast?.Name ?? "Podcast",
+ Album = episode.Published,
+ DurationMs = 0
+ };
+ await _player.PlayTrackAsync(track);
+ }
+ else
+ {
+ if (!Uri.TryCreate(episode.AudioUrl, UriKind.Absolute, out var uri)) return;
+ await _player.PlayStreamAsync(uri);
+ }
+
+ // Resume from saved progress
+ if (_episodeProgress.TryGetValue(episode.AudioUrl, out var savedPos) && savedPos > 1)
+ {
+ _player.Seek(TimeSpan.FromSeconds(savedPos));
+ }
// Update now-playing display
TrackTitle.Text = episode.Title;
@@ -2466,6 +2772,9 @@ private void NavMore_Click(object sender, RoutedEventArgs e)
panel.Children.Add(ActionPanel.CreateButton("\uE93F", "Albums", [],
() => { flyout.Hide(); NavAlbums_Click(sender, e); },
isActive: _viewMode == ViewMode.Albums || _viewMode == ViewMode.AlbumDetail));
+ panel.Children.Add(ActionPanel.CreateButton("\uE77B", "Artists", [],
+ () => { flyout.Hide(); NavArtists_Click(sender, e); },
+ isActive: _viewMode == ViewMode.Artists || _viewMode == ViewMode.ArtistDetail));
panel.Children.Add(ActionPanel.CreateButton("\uE9D9", "Visualizer", [],
() => { flyout.Hide(); NavVisualizer_Click(sender, e); },
isActive: _viewMode == ViewMode.Visualizer));
@@ -2649,13 +2958,160 @@ await Task.Run(() =>
private void AlbumGrid_ItemClick(object sender, ItemClickEventArgs e)
{
- if (e.ClickedItem is not StackPanel card || card.Tag is not string albumName) return;
+ if (e.ClickedItem is not StackPanel card || card.Tag is not string name) return;
+
+ if (_viewMode == ViewMode.Artists)
+ {
+ _currentArtistName = name;
+ _viewMode = ViewMode.ArtistDetail;
+ _currentPlaylist = null;
+ UpdateNavigation();
+ AnimateViewTransition(() => ApplyFilterAndSort());
+ }
+ else
+ {
+ _currentAlbumName = name;
+ _viewMode = ViewMode.AlbumDetail;
+ _currentPlaylist = null;
+ UpdateNavigation();
+ AnimateViewTransition(() => ApplyFilterAndSort());
+ }
+ }
+
+ // -- Artists ---------------------------------------------------
- _currentAlbumName = albumName;
- _viewMode = ViewMode.AlbumDetail;
+ private void NavArtists_Click(object sender, RoutedEventArgs e)
+ {
+ if (_viewMode == ViewMode.Artists) return;
+ _viewMode = ViewMode.Artists;
_currentPlaylist = null;
+ _currentArtistName = null;
UpdateNavigation();
- AnimateViewTransition(() => ApplyFilterAndSort());
+ UpdateSpectrumTimer();
+ UpdateMediaTimer();
+ AnimateViewTransition(() => BuildArtistsGrid());
+ }
+
+ private void BuildArtistsGrid()
+ {
+ AlbumsGridView.Items.Clear();
+
+ var artistGroups = _allTracks
+ .Where(t => !string.IsNullOrWhiteSpace(t.Artist))
+ .GroupBy(t => t.Artist, StringComparer.OrdinalIgnoreCase)
+ .Select(g => (
+ Artist: g.Key,
+ AlbumCount: g.Select(t => t.Album).Where(a => !string.IsNullOrWhiteSpace(a)).Distinct(StringComparer.OrdinalIgnoreCase).Count(),
+ TrackCount: g.Count(),
+ SampleTrackPath: g.First().Path
+ ))
+ .OrderBy(a => a.Artist)
+ .ToList();
+
+ TrackCountText.Text = $"{artistGroups.Count:N0} artists";
+
+ foreach (var artist in artistGroups)
+ {
+ var card = new StackPanel
+ {
+ Width = 150,
+ Spacing = 4,
+ Padding = new Thickness(4),
+ Tag = artist.Artist
+ };
+
+ // Artist art container (circular)
+ var artGrid = new Grid
+ {
+ Width = 142,
+ Height = 142,
+ CornerRadius = new CornerRadius(71),
+ Background = (Brush)Application.Current.Resources["CardBackgroundFillColorSecondaryBrush"]
+ };
+
+ var artImage = new Image
+ {
+ Stretch = Stretch.UniformToFill,
+ Width = 142,
+ Height = 142,
+ Visibility = Visibility.Collapsed
+ };
+
+ // Clip to circle
+ var clip = new RectangleGeometry
+ {
+ Rect = new Windows.Foundation.Rect(0, 0, 142, 142)
+ };
+ var ellipseClip = new Microsoft.UI.Xaml.Media.EllipseGeometry
+ {
+ Center = new Windows.Foundation.Point(71, 71),
+ RadiusX = 71,
+ RadiusY = 71
+ };
+
+ var placeholder = new FontIcon
+ {
+ Glyph = "\uE77B",
+ FontSize = 36,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center,
+ Foreground = ThemeHelper.Brush("TextFillColorTertiaryBrush")
+ };
+
+ artGrid.Children.Add(placeholder);
+ artGrid.Children.Add(artImage);
+ card.Children.Add(artGrid);
+
+ // Artist name
+ card.Children.Add(new TextBlock
+ {
+ Text = artist.Artist,
+ FontSize = 12,
+ FontWeight = Microsoft.UI.Text.FontWeights.SemiBold,
+ TextTrimming = TextTrimming.CharacterEllipsis,
+ MaxLines = 1,
+ HorizontalTextAlignment = TextAlignment.Center,
+ Margin = new Thickness(2, 2, 0, 0)
+ });
+
+ // Album + track count
+ var albumText = artist.AlbumCount == 1 ? "1 album" : $"{artist.AlbumCount} albums";
+ card.Children.Add(new TextBlock
+ {
+ Text = $"{albumText} \u00B7 {artist.TrackCount} tracks",
+ FontSize = 11,
+ Foreground = ThemeHelper.Brush("TextFillColorSecondaryBrush"),
+ TextTrimming = TextTrimming.CharacterEllipsis,
+ MaxLines = 1,
+ HorizontalTextAlignment = TextAlignment.Center,
+ Margin = new Thickness(2, 0, 0, 0)
+ });
+
+ AlbumsGridView.Items.Add(card);
+
+ // Load artwork async (reuse album art loader)
+ var capturedImage = artImage;
+ var capturedPlaceholder = placeholder;
+ var capturedPath = artist.SampleTrackPath;
+ _ = LoadAlbumCardArtAsync(capturedImage, capturedPlaceholder, capturedPath);
+ }
+
+ if (artistGroups.Count == 0)
+ {
+ var empty = new TextBlock
+ {
+ Text = "No artists found. Add music folders in Settings.",
+ Foreground = ThemeHelper.Brush("TextFillColorTertiaryBrush"),
+ FontSize = 13,
+ Margin = new Thickness(8)
+ };
+ AlbumsGridView.Items.Add(empty);
+ }
+ }
+
+ private void ArtistBack_Click(object sender, RoutedEventArgs e)
+ {
+ NavArtists_Click(sender, e);
}
// -- Equalizer ------------------------------------------------
@@ -3433,13 +3889,22 @@ private void TrackListView_DragItemsCompleted(ListViewBase sender, DragItemsComp
// -- Drag & drop from Explorer --------------------------------
- private void RootGrid_DragOver(object sender, DragEventArgs e)
+ private async void RootGrid_DragOver(object sender, DragEventArgs e)
{
if (e.DataView.Contains(StandardDataFormats.StorageItems))
{
e.AcceptedOperation = DataPackageOperation.Copy;
- e.DragUIOverride.Caption = "Add to Queue";
- e.DragUIOverride.IsCaptionVisible = true;
+
+ // Check if drop contains folders
+ var deferral = e.GetDeferral();
+ try
+ {
+ var items = await e.DataView.GetStorageItemsAsync();
+ bool hasFolder = items.Any(i => i is StorageFolder);
+ e.DragUIOverride.Caption = hasFolder ? "Add to Library" : "Add to Queue";
+ e.DragUIOverride.IsCaptionVisible = true;
+ }
+ finally { deferral.Complete(); }
}
}
@@ -3449,10 +3914,15 @@ private async void RootGrid_Drop(object sender, DragEventArgs e)
var items = await e.DataView.GetStorageItemsAsync();
var audioFiles = new List();
+ var folders = new List();
foreach (var item in items)
{
- if (item is StorageFile file)
+ if (item is StorageFolder folder)
+ {
+ folders.Add(folder);
+ }
+ else if (item is StorageFile file)
{
var ext = Path.GetExtension(file.Path).ToLowerInvariant();
if (LibraryManager.AudioExtensions.Contains(ext))
@@ -3460,6 +3930,20 @@ private async void RootGrid_Drop(object sender, DragEventArgs e)
}
}
+ // Handle folder drops — add to library and scan
+ if (folders.Count > 0)
+ {
+ TrackCountText.Text = "Scanning...";
+ foreach (var folder in folders)
+ {
+ var folderId = LibraryManager.AddFolder(folder.Path);
+ await LibraryManager.ScanFolderAsync(folderId, folder.Path);
+ }
+ LoadTracks();
+ return;
+ }
+
+ // Handle file drops — add to queue
if (audioFiles.Count == 0) return;
bool queueWasEmpty = _queue.Queue.Count == 0;
@@ -3686,6 +4170,17 @@ void AddFpsOption(int fps, string label)
Pin_Click(this, new RoutedEventArgs());
}));
+ // Sleep timer
+ {
+ var sleepLabel = IsSleepTimerActive
+ ? $"Sleep ({(int)SleepTimeRemaining.TotalMinutes} min)"
+ : "Sleep Timer";
+ panel.Children.Add(ActionPanel.CreateButton("\uE823", sleepLabel, [], () =>
+ {
+ ShowSleepTimerFlyout(flyout, anchor);
+ }, isActive: IsSleepTimerActive));
+ }
+
panel.Children.Add(ActionPanel.CreateSeparator());
// Quit
@@ -3936,19 +4431,42 @@ private void ShowAcrylicSettingsInFlyout(Flyout flyout, FrameworkElement anchor)
});
// Tint Color
- var tintColorPreview = new Border
+ var tintColorPreview = new Button
{
Width = 28, Height = 28, CornerRadius = new CornerRadius(4),
- BorderThickness = new Thickness(1),
+ BorderThickness = new Thickness(1), Padding = new Thickness(0),
+ MinWidth = 0, MinHeight = 0,
BorderBrush = ThemeHelper.Brush("ControlStrokeColorDefaultBrush"),
Background = new SolidColorBrush(ParseColor(settings.TintColor))
};
+ ToolTipService.SetToolTip(tintColorPreview, "Choose color");
var tintColorBox = new TextBox
{
Text = settings.TintColor, FontSize = 12, MaxLength = 7,
MinWidth = 0
};
+ // Color picker flyout for tint
+ var tintPickerFlyout = new Flyout();
+ var tintPicker = new ColorPicker
+ {
+ Color = ParseColor(settings.TintColor),
+ IsAlphaEnabled = false,
+ IsHexInputVisible = true,
+ IsColorSpectrumVisible = true,
+ IsColorPreviewVisible = true,
+ IsMoreButtonVisible = false
+ };
+ tintPicker.ColorChanged += (_, args) =>
+ {
+ var c = args.NewColor;
+ var hex = $"#{c.R:X2}{c.G:X2}{c.B:X2}";
+ tintColorBox.Text = hex;
+ tintColorPreview.Background = new SolidColorBrush(c);
+ };
+ tintPickerFlyout.Content = tintPicker;
+ tintColorPreview.Flyout = tintPickerFlyout;
+
var tintColorGrid = new Grid
{
ColumnSpacing = 8,
@@ -3972,19 +4490,42 @@ private void ShowAcrylicSettingsInFlyout(Flyout flyout, FrameworkElement anchor)
panel.Children.Add(tintColorGrid);
// Fallback Color
- var fallbackColorPreview = new Border
+ var fallbackColorPreview = new Button
{
Width = 28, Height = 28, CornerRadius = new CornerRadius(4),
- BorderThickness = new Thickness(1),
+ BorderThickness = new Thickness(1), Padding = new Thickness(0),
+ MinWidth = 0, MinHeight = 0,
BorderBrush = ThemeHelper.Brush("ControlStrokeColorDefaultBrush"),
Background = new SolidColorBrush(ParseColor(settings.FallbackColor))
};
+ ToolTipService.SetToolTip(fallbackColorPreview, "Choose color");
var fallbackColorBox = new TextBox
{
Text = settings.FallbackColor, FontSize = 12, MaxLength = 7,
MinWidth = 0
};
+ // Color picker flyout for fallback
+ var fallbackPickerFlyout = new Flyout();
+ var fallbackPicker = new ColorPicker
+ {
+ Color = ParseColor(settings.FallbackColor),
+ IsAlphaEnabled = false,
+ IsHexInputVisible = true,
+ IsColorSpectrumVisible = true,
+ IsColorPreviewVisible = true,
+ IsMoreButtonVisible = false
+ };
+ fallbackPicker.ColorChanged += (_, args) =>
+ {
+ var c = args.NewColor;
+ var hex = $"#{c.R:X2}{c.G:X2}{c.B:X2}";
+ fallbackColorBox.Text = hex;
+ fallbackColorPreview.Background = new SolidColorBrush(c);
+ };
+ fallbackPickerFlyout.Content = fallbackPicker;
+ fallbackColorPreview.Flyout = fallbackPickerFlyout;
+
var fallbackColorGrid = new Grid
{
ColumnSpacing = 8,
@@ -4388,14 +4929,14 @@ private void ToggleCollapse()
VolumeRow.Visibility = Visibility.Visible;
NavRow.Visibility = Visibility.Visible;
SearchSortRow.Visibility = (_viewMode == ViewMode.Library || _viewMode == ViewMode.PlaylistDetail
- || _viewMode == ViewMode.AlbumDetail)
+ || _viewMode == ViewMode.AlbumDetail || _viewMode == ViewMode.ArtistDetail)
? Visibility.Visible : Visibility.Collapsed;
var isPodcast = _viewMode == ViewMode.Podcast || _viewMode == ViewMode.PodcastEpisodes;
var isTrackView = _viewMode == ViewMode.Library || _viewMode == ViewMode.PlaylistList
|| _viewMode == ViewMode.PlaylistDetail || _viewMode == ViewMode.Queue
- || _viewMode == ViewMode.AlbumDetail;
+ || _viewMode == ViewMode.AlbumDetail || _viewMode == ViewMode.ArtistDetail;
TrackListView.Visibility = isTrackView ? Visibility.Visible : Visibility.Collapsed;
- AlbumsGridView.Visibility = _viewMode == ViewMode.Albums
+ AlbumsGridView.Visibility = (_viewMode == ViewMode.Albums || _viewMode == ViewMode.Artists)
? Visibility.Visible : Visibility.Collapsed;
WaveformContainer.Visibility = _viewMode == ViewMode.Visualizer
? Visibility.Visible : Visibility.Collapsed;
@@ -4490,7 +5031,9 @@ private void AnimTick(object? sender, object e)
}
else
{
- _currentAnimHeight += (int)(diff * 0.18);
+ var step = (int)(diff * 0.18);
+ if (step == 0) step = diff > 0 ? 1 : -1;
+ _currentAnimHeight += step;
var newY = _targetY + (_targetHeight - _currentAnimHeight);
AppWindow.MoveAndResize(new Windows.Graphics.RectInt32(
AppWindow.Position.X, newY,
@@ -4736,4 +5279,83 @@ private static string FormatTime(TimeSpan ts)
return $"{(int)ts.TotalHours}:{ts.Minutes:D2}:{ts.Seconds:D2}";
return $"{(int)ts.TotalMinutes}:{ts.Seconds:D2}";
}
+
+ // -- Sleep Timer ------------------------------------------------
+
+ private void SetSleepTimer(int minutes)
+ {
+ CancelSleepTimer();
+
+ if (minutes <= 0) return;
+
+ _sleepTargetTime = DateTime.Now.AddMinutes(minutes);
+ _sleepTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
+ _sleepTimer.Tick += SleepTimer_Tick;
+ _sleepTimer.Start();
+ }
+
+ private void CancelSleepTimer()
+ {
+ _sleepTimer?.Stop();
+ _sleepTimer = null;
+ }
+
+ private bool IsSleepTimerActive => _sleepTimer != null;
+
+ private TimeSpan SleepTimeRemaining =>
+ IsSleepTimerActive ? _sleepTargetTime - DateTime.Now : TimeSpan.Zero;
+
+ private void SleepTimer_Tick(object? sender, object e)
+ {
+ var remaining = _sleepTargetTime - DateTime.Now;
+ if (remaining <= TimeSpan.Zero)
+ {
+ CancelSleepTimer();
+ _player.Stop();
+ PlayPauseIcon.Glyph = "\uE768";
+ MiniPlayPauseIcon.Glyph = "\uE768";
+ }
+ }
+
+ private void ShowSleepTimerFlyout(Flyout parentFlyout, FrameworkElement anchor)
+ {
+ parentFlyout.Hide();
+
+ var flyout = new Flyout();
+ flyout.FlyoutPresenterStyle = ActionPanel.CreateFlyoutPresenterStyle(minWidth: 180, maxWidth: 220);
+
+ var panel = new StackPanel { Spacing = 0 };
+ panel.Children.Add(ActionPanel.CreateSectionHeader("Sleep Timer"));
+ panel.Children.Add(ActionPanel.CreateSeparator());
+
+ if (IsSleepTimerActive)
+ {
+ var rem = SleepTimeRemaining;
+ var remText = rem.TotalMinutes >= 1
+ ? $"{(int)rem.TotalMinutes} min remaining"
+ : $"{(int)rem.TotalSeconds}s remaining";
+ panel.Children.Add(ActionPanel.CreateSectionHeader(remText));
+ panel.Children.Add(ActionPanel.CreateButton("\uE711", "Cancel Timer", [], () =>
+ {
+ CancelSleepTimer();
+ flyout.Hide();
+ }, isDestructive: true));
+ }
+ else
+ {
+ foreach (var mins in new[] { 15, 30, 45, 60, 90 })
+ {
+ var m = mins;
+ var label = m >= 60 ? $"{m / 60}h{(m % 60 > 0 ? $" {m % 60}min" : "")}" : $"{m} min";
+ panel.Children.Add(ActionPanel.CreateButton("\uE823", label, [], () =>
+ {
+ SetSleepTimer(m);
+ flyout.Hide();
+ }));
+ }
+ }
+
+ flyout.Content = panel;
+ flyout.ShowAt(anchor);
+ }
}
diff --git a/Audiomatic/Models/TrackInfo.cs b/Audiomatic/Models/TrackInfo.cs
index 2d5b1c4..c2c4960 100644
--- a/Audiomatic/Models/TrackInfo.cs
+++ b/Audiomatic/Models/TrackInfo.cs
@@ -16,6 +16,7 @@ public sealed class TrackInfo
public long LastModified { get; set; }
public long CreatedAt { get; set; }
public bool IsFavorite { get; set; }
+ public int Bpm { get; set; }
public string DurationFormatted
{
diff --git a/Audiomatic/Services/AudioPlayerService.cs b/Audiomatic/Services/AudioPlayerService.cs
index f9eff4d..32708c6 100644
--- a/Audiomatic/Services/AudioPlayerService.cs
+++ b/Audiomatic/Services/AudioPlayerService.cs
@@ -26,6 +26,18 @@ public sealed class AudioPlayerService : IDisposable
private bool _eqEnabled = true;
private float _eqPreampDb;
+ // Speed control
+ private SpeedControlSampleProvider? _speedProvider;
+ private float _playbackSpeed = 1.0f;
+
+ // Gapless playback
+ private GaplessSampleProvider? _gaplessProvider;
+ private AudioFileReader? _nextAudioReader;
+ private Equalizer? _nextEqualizer;
+ private SpeedControlSampleProvider? _nextSpeedProvider;
+ private TrackInfo? _nextTrack;
+ private bool _gaplessTransitioning;
+
// NAudio-supported but not natively by MediaPlayer
private static readonly HashSet NAudioOnlyExtensions =
new(StringComparer.OrdinalIgnoreCase) { ".ape", ".aiff" };
@@ -38,6 +50,8 @@ public sealed class AudioPlayerService : IDisposable
public event Action? MediaFailed;
public event Action? PositionChanged;
public event Action? BufferingChanged;
+ /// Fired when gapless transition occurs — the next track started seamlessly.
+ public event Action? GaplessTransitioned;
public TrackInfo? CurrentTrack { get; private set; }
public bool IsPlaying { get; private set; }
@@ -149,11 +163,24 @@ public async Task PlayTrackAsync(TrackInfo track)
_equalizer.Preamp = DbToLinear(_eqPreampDb);
_audioReader.Volume = _isMuted ? 0f : (float)_volume;
+ _speedProvider = new SpeedControlSampleProvider(_equalizer) { Speed = _playbackSpeed };
+
+ // Wrap in gapless provider
+ _gaplessProvider = new GaplessSampleProvider(_speedProvider);
+ _gaplessProvider.SourceTransitioned += OnGaplessTransition;
+ _gaplessProvider.PlaybackEnded += () =>
+ {
+ IsPlaying = false;
+ _dispatcherQueue?.TryEnqueue(() => MediaEnded?.Invoke());
+ };
+
_waveOut = new WasapiOut();
- _waveOut.Init(new NAudio.Wave.SampleProviders.SampleToWaveProvider16(_equalizer));
+ _waveOut.Init(new NAudio.Wave.SampleProviders.SampleToWaveProvider16(_gaplessProvider));
_waveOut.PlaybackStopped += (_, _) =>
{
- if (_audioReader != null && _audioReader.CurrentTime >= _audioReader.TotalTime - TimeSpan.FromMilliseconds(500))
+ // Only fire MediaEnded for non-gapless stops (e.g., user pressed stop)
+ if (!_gaplessTransitioning && _audioReader != null
+ && _audioReader.CurrentTime >= _audioReader.TotalTime - TimeSpan.FromMilliseconds(500))
{
IsPlaying = false;
_dispatcherQueue?.TryEnqueue(() => MediaEnded?.Invoke());
@@ -174,6 +201,108 @@ public async Task PlayTrackAsync(TrackInfo track)
}
}
+ ///
+ /// Pre-build the audio chain for the next track so it can transition gaplessly.
+ /// Call this when the current track is nearing its end.
+ ///
+ public void PrepareNextTrack(TrackInfo track)
+ {
+ if (_gaplessProvider == null || !_useNAudio) return;
+ if (_gaplessProvider.HasNext) return; // already prepared
+
+ try
+ {
+ DisposeNextChain();
+ _nextAudioReader = new AudioFileReader(track.Path);
+
+ // Check format compatibility
+ if (_audioReader != null &&
+ (_nextAudioReader.WaveFormat.SampleRate != _audioReader.WaveFormat.SampleRate ||
+ _nextAudioReader.WaveFormat.Channels != _audioReader.WaveFormat.Channels))
+ {
+ // Incompatible formats — can't do gapless, will fall back to normal transition
+ DisposeNextChain();
+ return;
+ }
+
+ _nextEqualizer = new Equalizer(_nextAudioReader);
+ _nextEqualizer.Enabled = _eqEnabled;
+ _nextEqualizer.SetAllBands(_eqGains);
+ _nextEqualizer.Preamp = DbToLinear(_eqPreampDb);
+
+ _nextAudioReader.Volume = _isMuted ? 0f : (float)_volume;
+ _nextSpeedProvider = new SpeedControlSampleProvider(_nextEqualizer) { Speed = _playbackSpeed };
+
+ _nextTrack = track;
+ _gaplessProvider.QueueNext(_nextSpeedProvider);
+ }
+ catch
+ {
+ DisposeNextChain();
+ }
+ }
+
+ private void OnGaplessTransition()
+ {
+ _gaplessTransitioning = true;
+
+ // Dispose old chain
+ var oldReader = _audioReader;
+ var oldEqualizer = _equalizer;
+
+ // Promote next chain to current
+ _audioReader = _nextAudioReader;
+ _equalizer = _nextEqualizer;
+ _speedProvider = _nextSpeedProvider;
+ _nextAudioReader = null;
+ _nextEqualizer = null;
+ _nextSpeedProvider = null;
+
+ var transitionedTrack = _nextTrack;
+ _nextTrack = null;
+ CurrentTrack = transitionedTrack;
+
+ // Dispose old reader on background thread
+ Task.Run(() =>
+ {
+ try { oldReader?.Dispose(); } catch { }
+ });
+
+ if (transitionedTrack != null)
+ {
+ UpdateSmtc(transitionedTrack);
+ _dispatcherQueue?.TryEnqueue(() =>
+ {
+ GaplessTransitioned?.Invoke(transitionedTrack);
+ MediaOpened?.Invoke();
+ });
+ }
+
+ _gaplessTransitioning = false;
+ }
+
+ private void DisposeNextChain()
+ {
+ try { _nextAudioReader?.Dispose(); } catch { }
+ _nextAudioReader = null;
+ _nextEqualizer = null;
+ _nextSpeedProvider = null;
+ _nextTrack = null;
+ }
+
+ ///
+ /// Returns how many seconds remain in the current track.
+ /// Returns -1 if not applicable (stream, no track).
+ ///
+ public double RemainingSeconds
+ {
+ get
+ {
+ if (!_useNAudio || _audioReader == null) return -1;
+ return (_audioReader.TotalTime - _audioReader.CurrentTime).TotalSeconds / _playbackSpeed;
+ }
+ }
+
public bool IsStream { get; private set; }
public async Task PlayStreamAsync(Uri streamUri)
@@ -213,6 +342,7 @@ void onFailed(MediaPlayer mp, MediaPlayerFailedEventArgs args)
_dispatcherQueue?.TryEnqueue(() => BufferingChanged?.Invoke(false));
_mediaPlayer.Volume = _volume;
+ _mediaPlayer.PlaybackSession.PlaybackRate = _playbackSpeed;
_mediaPlayer.Play();
// Wait up to 15s for the stream to open
@@ -279,6 +409,9 @@ public void Stop()
{
if (_useNAudio)
{
+ _gaplessProvider?.ClearNext();
+ _gaplessProvider = null;
+ DisposeNextChain();
_waveOut?.Stop();
_waveOut?.Dispose();
_waveOut = null;
@@ -292,6 +425,7 @@ public void Stop()
}
IsPlaying = false;
IsStream = false;
+ _gaplessTransitioning = false;
PlaybackStopped?.Invoke();
}
@@ -299,6 +433,7 @@ public void Seek(TimeSpan position)
{
if (_useNAudio && _audioReader != null)
{
+ _speedProvider?.Reset();
_audioReader.CurrentTime = position;
}
else
@@ -422,6 +557,21 @@ public void SetEqPreamp(float db)
private static float DbToLinear(float db) => MathF.Pow(10f, db / 20f);
+ // -- Speed control --
+
+ public float PlaybackSpeed
+ {
+ get => _playbackSpeed;
+ set
+ {
+ _playbackSpeed = Math.Clamp(value, 0.25f, 4.0f);
+ if (_speedProvider != null)
+ _speedProvider.Speed = _playbackSpeed;
+ if (!_useNAudio)
+ _mediaPlayer.PlaybackSession.PlaybackRate = _playbackSpeed;
+ }
+ }
+
public void SuspendPositionTimer()
{
_positionTimer?.Change(Timeout.Infinite, Timeout.Infinite);
@@ -437,6 +587,7 @@ public void Dispose()
_positionTimer?.Dispose();
_positionTimer = null;
Stop();
+ DisposeNextChain();
_albumArtStream?.Dispose();
_albumArtStream = null;
_mediaPlayer.Dispose();
diff --git a/Audiomatic/Services/BpmDetector.cs b/Audiomatic/Services/BpmDetector.cs
new file mode 100644
index 0000000..a3390a1
--- /dev/null
+++ b/Audiomatic/Services/BpmDetector.cs
@@ -0,0 +1,290 @@
+using NAudio.Wave;
+
+namespace Audiomatic.Services;
+
+///
+/// Detects BPM from audio files using multi-band spectral flux onset detection
+/// combined with comb-filter tempo estimation.
+///
+public static class BpmDetector
+{
+ private const int AnalysisSeconds = 30;
+ private const int MinBpm = 60;
+ private const int MaxBpm = 200;
+
+ ///
+ /// Analyze the audio file and return estimated BPM (0 if detection fails).
+ ///
+ public static int Detect(string filePath)
+ {
+ try
+ {
+ using var reader = new AudioFileReader(filePath);
+ var sampleRate = reader.WaveFormat.SampleRate;
+ var channels = reader.WaveFormat.Channels;
+
+ // Skip first 20% to avoid intros, analyze up to 30s
+ var totalSeconds = reader.TotalTime.TotalSeconds;
+ var startSec = totalSeconds * 0.2;
+ var analysisSec = Math.Min(AnalysisSeconds, totalSeconds - startSec);
+ if (analysisSec < 4) return 0;
+
+ // Seek to start
+ reader.CurrentTime = TimeSpan.FromSeconds(startSec);
+
+ var samplesToRead = (int)(analysisSec * sampleRate) * channels;
+ var mono = ReadMono(reader, samplesToRead, channels);
+ if (mono.Length < sampleRate * 4) return 0;
+
+ // Low-pass filter to isolate bass/kick (< 200Hz) — most reliable for tempo
+ var bassSignal = LowPassFilter(mono, sampleRate, 200f);
+
+ // Also keep a mid-range signal (200-5000Hz) for snare/hi-hat detection
+ var midSignal = BandPassFilter(mono, sampleRate, 200f, 5000f);
+
+ // Compute onset strength envelopes (~86 Hz resolution = ~11.6ms windows)
+ var hopSize = sampleRate / 86;
+ var bassOnsets = ComputeOnsetEnvelope(bassSignal, hopSize);
+ var midOnsets = ComputeOnsetEnvelope(midSignal, hopSize);
+
+ // Combine: bass weighted 2x more than mid
+ var onsetRate = (float)sampleRate / hopSize;
+ var combined = new float[Math.Min(bassOnsets.Length, midOnsets.Length)];
+ for (int i = 0; i < combined.Length; i++)
+ combined[i] = bassOnsets[i] * 2f + midOnsets[i];
+
+ // Adaptive threshold — suppress low-energy onsets
+ AdaptiveThreshold(combined, (int)(onsetRate * 0.3f));
+
+ // Comb filter BPM estimation
+ var bestBpm = CombFilterEstimate(combined, onsetRate, MinBpm, MaxBpm);
+
+ // Verify with autocorrelation
+ var autoBpm = AutocorrelationEstimate(combined, onsetRate, MinBpm, MaxBpm);
+
+ // If both methods agree within 8%, use the average; otherwise prefer comb filter
+ if (bestBpm > 0 && autoBpm > 0)
+ {
+ var ratio = (float)Math.Max(bestBpm, autoBpm) / Math.Min(bestBpm, autoBpm);
+ if (ratio < 1.08f)
+ bestBpm = (int)Math.Round((bestBpm + autoBpm) / 2.0);
+ // Check if one is a harmonic of the other
+ else if (Math.Abs(bestBpm - autoBpm * 2) < 8)
+ bestBpm = autoBpm * 2 > MaxBpm ? autoBpm : autoBpm * 2;
+ else if (Math.Abs(autoBpm - bestBpm * 2) < 8)
+ bestBpm = bestBpm * 2 > MaxBpm ? bestBpm : bestBpm * 2;
+ }
+
+ // Normalize to common range
+ while (bestBpm > MaxBpm) bestBpm /= 2;
+ while (bestBpm > 0 && bestBpm < MinBpm) bestBpm *= 2;
+
+ return bestBpm;
+ }
+ catch
+ {
+ return 0;
+ }
+ }
+
+ /// Comb filter tempo estimation — peaks at the correct BPM.
+ private static int CombFilterEstimate(float[] onsets, float onsetRate, int minBpm, int maxBpm)
+ {
+ var bestBpm = 0;
+ var bestEnergy = 0.0;
+
+ // Test each BPM candidate (0.5 BPM resolution)
+ for (int bpmX2 = minBpm * 2; bpmX2 <= maxBpm * 2; bpmX2++)
+ {
+ var bpm = bpmX2 / 2.0;
+ var lagSamples = onsetRate * 60.0 / bpm;
+
+ // Sum onset energy at multiples of this lag (comb filter)
+ double energy = 0;
+ int pulses = 0;
+ for (int harmonic = 1; harmonic <= 4; harmonic++)
+ {
+ var step = lagSamples * harmonic;
+ if (step >= onsets.Length) break;
+
+ for (double pos = 0; pos < onsets.Length; pos += step)
+ {
+ var idx = (int)pos;
+ if (idx < onsets.Length)
+ {
+ // Check a small window around the expected position
+ var window = Math.Max(1, (int)(lagSamples * 0.06));
+ var lo = Math.Max(0, idx - window);
+ var hi = Math.Min(onsets.Length - 1, idx + window);
+ float peak = 0;
+ for (int j = lo; j <= hi; j++)
+ peak = Math.Max(peak, onsets[j]);
+ energy += peak;
+ pulses++;
+ }
+ }
+ }
+
+ if (pulses > 0) energy /= pulses;
+
+ if (energy > bestEnergy)
+ {
+ bestEnergy = energy;
+ bestBpm = (int)Math.Round(bpm);
+ }
+ }
+
+ return bestBpm;
+ }
+
+ /// Autocorrelation-based BPM estimation.
+ private static int AutocorrelationEstimate(float[] onsets, float onsetRate, int minBpm, int maxBpm)
+ {
+ var minLag = (int)(onsetRate * 60.0 / maxBpm);
+ var maxLag = (int)(onsetRate * 60.0 / minBpm);
+ maxLag = Math.Min(maxLag, onsets.Length / 2);
+ if (minLag >= maxLag) return 0;
+
+ // Compute autocorrelation
+ var corr = new double[maxLag + 1];
+ for (int lag = minLag; lag <= maxLag; lag++)
+ {
+ double sum = 0;
+ int count = onsets.Length - lag;
+ for (int i = 0; i < count; i++)
+ sum += onsets[i] * onsets[i + lag];
+ corr[lag] = sum / count;
+ }
+
+ // Find peaks in autocorrelation (not just the max — look for the most prominent)
+ var bestLag = minLag;
+ var bestVal = 0.0;
+
+ for (int lag = minLag + 1; lag < maxLag; lag++)
+ {
+ // Must be a local peak
+ if (corr[lag] > corr[lag - 1] && corr[lag] > corr[lag + 1] && corr[lag] > bestVal)
+ {
+ bestVal = corr[lag];
+ bestLag = lag;
+ }
+ }
+
+ return (int)Math.Round(onsetRate * 60.0 / bestLag);
+ }
+
+ /// Compute onset strength envelope using half-wave rectified spectral flux.
+ private static float[] ComputeOnsetEnvelope(float[] samples, int hopSize)
+ {
+ var count = samples.Length / hopSize;
+ if (count < 2) return [];
+
+ var envelope = new float[count];
+
+ // Compute RMS energy per window
+ for (int i = 0; i < count; i++)
+ {
+ float energy = 0;
+ var offset = i * hopSize;
+ for (int j = 0; j < hopSize && offset + j < samples.Length; j++)
+ {
+ var s = samples[offset + j];
+ energy += s * s;
+ }
+ envelope[i] = MathF.Sqrt(energy / hopSize);
+ }
+
+ // Spectral flux: positive first-order difference
+ var flux = new float[count];
+ for (int i = 1; i < count; i++)
+ {
+ var diff = envelope[i] - envelope[i - 1];
+ flux[i] = diff > 0 ? diff : 0;
+ }
+
+ return flux;
+ }
+
+ /// Adaptive threshold: subtract local mean to suppress noise.
+ private static void AdaptiveThreshold(float[] signal, int windowHalf)
+ {
+ if (windowHalf < 1) windowHalf = 1;
+ var mean = new float[signal.Length];
+
+ // Running mean
+ double sum = 0;
+ int count = 0;
+ for (int i = 0; i < signal.Length; i++)
+ {
+ sum += signal[i];
+ count++;
+ if (i >= windowHalf * 2)
+ {
+ sum -= signal[i - windowHalf * 2];
+ count--;
+ }
+ mean[i] = (float)(sum / count);
+ }
+
+ for (int i = 0; i < signal.Length; i++)
+ {
+ signal[i] = signal[i] > mean[i] * 1.5f ? signal[i] - mean[i] : 0;
+ }
+ }
+
+ /// Simple first-order IIR low-pass filter.
+ private static float[] LowPassFilter(float[] samples, int sampleRate, float cutoffHz)
+ {
+ var rc = 1.0f / (2.0f * MathF.PI * cutoffHz);
+ var dt = 1.0f / sampleRate;
+ var alpha = dt / (rc + dt);
+
+ var output = new float[samples.Length];
+ output[0] = samples[0];
+ for (int i = 1; i < samples.Length; i++)
+ output[i] = output[i - 1] + alpha * (samples[i] - output[i - 1]);
+ return output;
+ }
+
+ /// Band-pass: high-pass then low-pass.
+ private static float[] BandPassFilter(float[] samples, int sampleRate, float lowHz, float highHz)
+ {
+ // High-pass
+ var rcHigh = 1.0f / (2.0f * MathF.PI * lowHz);
+ var dt = 1.0f / sampleRate;
+ var alphaHigh = rcHigh / (rcHigh + dt);
+
+ var hp = new float[samples.Length];
+ hp[0] = samples[0];
+ for (int i = 1; i < samples.Length; i++)
+ hp[i] = alphaHigh * (hp[i - 1] + samples[i] - samples[i - 1]);
+
+ // Low-pass
+ return LowPassFilter(hp, sampleRate, highHz);
+ }
+
+ private static float[] ReadMono(AudioFileReader reader, int sampleCount, int channels)
+ {
+ var buffer = new float[Math.Min(sampleCount, 1024 * 1024)];
+ var mono = new List(buffer.Length / channels);
+ int totalRead = 0;
+
+ while (totalRead < sampleCount)
+ {
+ var toRead = Math.Min(buffer.Length, sampleCount - totalRead);
+ var read = reader.Read(buffer, 0, toRead);
+ if (read == 0) break;
+ totalRead += read;
+
+ for (int i = 0; i < read; i += channels)
+ {
+ float sum = 0;
+ for (int ch = 0; ch < channels && i + ch < read; ch++)
+ sum += buffer[i + ch];
+ mono.Add(sum / channels);
+ }
+ }
+
+ return mono.ToArray();
+ }
+}
diff --git a/Audiomatic/Services/GaplessSampleProvider.cs b/Audiomatic/Services/GaplessSampleProvider.cs
new file mode 100644
index 0000000..99838a2
--- /dev/null
+++ b/Audiomatic/Services/GaplessSampleProvider.cs
@@ -0,0 +1,106 @@
+using NAudio.Wave;
+
+namespace Audiomatic.Services;
+
+///
+/// Sample provider that enables gapless playback by seamlessly switching
+/// from the current source to a pre-queued next source when the current one ends.
+///
+public sealed class GaplessSampleProvider : ISampleProvider
+{
+ private ISampleProvider? _current;
+ private ISampleProvider? _next;
+ private readonly object _lock = new();
+ private bool _currentEnded;
+
+ public WaveFormat WaveFormat { get; }
+
+ /// Fired on the audio thread when the current source ends and a next source takes over.
+ public event Action? SourceTransitioned;
+
+ /// Fired on the audio thread when the current source ends and no next source is queued.
+ public event Action? PlaybackEnded;
+
+ public GaplessSampleProvider(ISampleProvider initial)
+ {
+ _current = initial;
+ WaveFormat = initial.WaveFormat;
+ }
+
+ ///
+ /// Queue the next source for gapless transition.
+ /// Must have the same WaveFormat as the current source.
+ /// Returns false if formats don't match.
+ ///
+ public bool QueueNext(ISampleProvider next)
+ {
+ if (next.WaveFormat.SampleRate != WaveFormat.SampleRate ||
+ next.WaveFormat.Channels != WaveFormat.Channels)
+ return false;
+
+ lock (_lock)
+ {
+ _next = next;
+ }
+ return true;
+ }
+
+ /// Check if a next source is already queued.
+ public bool HasNext
+ {
+ get { lock (_lock) { return _next != null; } }
+ }
+
+ /// Clear the queued next source (e.g., when user skips manually).
+ public void ClearNext()
+ {
+ lock (_lock) { _next = null; }
+ }
+
+ /// Replace the current source entirely (for manual track changes).
+ public void SetCurrent(ISampleProvider source)
+ {
+ lock (_lock)
+ {
+ _current = source;
+ _next = null;
+ _currentEnded = false;
+ }
+ }
+
+ public int Read(float[] buffer, int offset, int count)
+ {
+ lock (_lock)
+ {
+ if (_current == null) return 0;
+
+ var read = _current.Read(buffer, offset, count);
+
+ if (read < count)
+ {
+ // Current source ended — try to switch to next
+ if (_next != null)
+ {
+ _current = _next;
+ _next = null;
+ _currentEnded = false;
+
+ // Fill remaining buffer from the new source
+ var remaining = count - read;
+ var nextRead = _current.Read(buffer, offset + read, remaining);
+ read += nextRead;
+
+ // Fire transition event (on audio thread — keep it fast)
+ SourceTransitioned?.Invoke();
+ }
+ else if (!_currentEnded && read == 0)
+ {
+ _currentEnded = true;
+ PlaybackEnded?.Invoke();
+ }
+ }
+
+ return read;
+ }
+ }
+}
diff --git a/Audiomatic/Services/LibraryManager.cs b/Audiomatic/Services/LibraryManager.cs
index 8059573..e63abd8 100644
--- a/Audiomatic/Services/LibraryManager.cs
+++ b/Audiomatic/Services/LibraryManager.cs
@@ -88,6 +88,22 @@ FOREIGN KEY (track_id) REFERENCES tracks(id) ON DELETE CASCADE
CREATE INDEX IF NOT EXISTS idx_tracks_album ON tracks(album);
""";
cmd.ExecuteNonQuery();
+
+ // Migration: add bpm column if missing
+ using var colCheck = conn.CreateCommand();
+ colCheck.CommandText = "PRAGMA table_info(tracks);";
+ bool hasBpm = false;
+ using (var reader = colCheck.ExecuteReader())
+ {
+ while (reader.Read())
+ if (reader.GetString(1) == "bpm") hasBpm = true;
+ }
+ if (!hasBpm)
+ {
+ using var alter = conn.CreateCommand();
+ alter.CommandText = "ALTER TABLE tracks ADD COLUMN bpm INTEGER NOT NULL DEFAULT 0;";
+ alter.ExecuteNonQuery();
+ }
}
// ── Folder management ────────────────────────────────────
@@ -237,6 +253,7 @@ private static TrackInfo ReadMetadata(string filePath, long folderId)
TrackNumber = (int)tagFile.Tag.Track,
Year = (int)tagFile.Tag.Year,
Genre = tagFile.Tag.FirstGenre ?? "",
+ Bpm = (int)tagFile.Tag.BeatsPerMinute,
FolderId = folderId,
Hash = ComputeFileHash(filePath),
LastModified = new DateTimeOffset(fi.LastWriteTimeUtc).ToUnixTimeSeconds(),
@@ -250,8 +267,8 @@ private static void InsertTrack(SqliteConnection conn, TrackInfo t, SqliteTransa
cmd.Transaction = transaction;
cmd.CommandText = """
INSERT OR REPLACE INTO tracks
- (path, title, artist, album, duration_ms, track_number, year, genre, folder_id, hash, last_modified, created_at)
- VALUES (@path, @title, @artist, @album, @dur, @tn, @year, @genre, @fid, @hash, @lm, @ca);
+ (path, title, artist, album, duration_ms, track_number, year, genre, bpm, folder_id, hash, last_modified, created_at)
+ VALUES (@path, @title, @artist, @album, @dur, @tn, @year, @genre, @bpm, @fid, @hash, @lm, @ca);
""";
cmd.Parameters.AddWithValue("@path", t.Path);
cmd.Parameters.AddWithValue("@title", t.Title);
@@ -261,6 +278,7 @@ INSERT OR REPLACE INTO tracks
cmd.Parameters.AddWithValue("@tn", t.TrackNumber);
cmd.Parameters.AddWithValue("@year", t.Year);
cmd.Parameters.AddWithValue("@genre", t.Genre);
+ cmd.Parameters.AddWithValue("@bpm", t.Bpm);
cmd.Parameters.AddWithValue("@fid", t.FolderId);
cmd.Parameters.AddWithValue("@hash", t.Hash);
cmd.Parameters.AddWithValue("@lm", t.LastModified);
@@ -288,7 +306,7 @@ public static List GetAllTracks()
using var cmd = conn.CreateCommand();
cmd.CommandText = """
SELECT t.id, t.path, t.title, t.artist, t.album, t.duration_ms,
- t.track_number, t.year, t.genre, t.folder_id, t.hash, t.last_modified, t.created_at,
+ t.track_number, t.year, t.genre, t.bpm, t.folder_id, t.hash, t.last_modified, t.created_at,
CASE WHEN f.track_id IS NOT NULL THEN 1 ELSE 0 END as is_fav
FROM tracks t
LEFT JOIN favorites f ON f.track_id = t.id
@@ -308,7 +326,7 @@ public static List SearchTracks(string query)
var q = $"%{query.Trim()}%";
cmd.CommandText = """
SELECT t.id, t.path, t.title, t.artist, t.album, t.duration_ms,
- t.track_number, t.year, t.genre, t.folder_id, t.hash, t.last_modified, t.created_at,
+ t.track_number, t.year, t.genre, t.bpm, t.folder_id, t.hash, t.last_modified, t.created_at,
CASE WHEN f.track_id IS NOT NULL THEN 1 ELSE 0 END as is_fav
FROM tracks t
LEFT JOIN favorites f ON f.track_id = t.id
@@ -346,11 +364,12 @@ private static List ReadTracks(SqliteCommand cmd)
TrackNumber = reader.GetInt32(6),
Year = reader.GetInt32(7),
Genre = reader.GetString(8),
- FolderId = reader.GetInt64(9),
- Hash = reader.GetString(10),
- LastModified = reader.GetInt64(11),
- CreatedAt = reader.GetInt64(12),
- IsFavorite = reader.GetInt64(13) == 1
+ Bpm = reader.GetInt32(9),
+ FolderId = reader.GetInt64(10),
+ Hash = reader.GetString(11),
+ LastModified = reader.GetInt64(12),
+ CreatedAt = reader.GetInt64(13),
+ IsFavorite = reader.GetInt64(14) == 1
});
}
return tracks;
@@ -381,7 +400,7 @@ public static List GetFavorites()
using var cmd = conn.CreateCommand();
cmd.CommandText = """
SELECT t.id, t.path, t.title, t.artist, t.album, t.duration_ms,
- t.track_number, t.year, t.genre, t.folder_id, t.hash, t.last_modified, t.created_at,
+ t.track_number, t.year, t.genre, t.bpm, t.folder_id, t.hash, t.last_modified, t.created_at,
1 as is_fav
FROM tracks t
INNER JOIN favorites f ON f.track_id = t.id
@@ -406,6 +425,18 @@ public static void UpdateTrackMetadata(long trackId, string title, string artist
cmd.ExecuteNonQuery();
}
+ public static void UpdateTrackBpm(long trackId, int bpm)
+ {
+ using var conn = new SqliteConnection(ConnectionString);
+ conn.Open();
+ EnablePragmas(conn);
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = "UPDATE tracks SET bpm = @bpm WHERE id = @id;";
+ cmd.Parameters.AddWithValue("@bpm", bpm);
+ cmd.Parameters.AddWithValue("@id", trackId);
+ cmd.ExecuteNonQuery();
+ }
+
// ── Playlist management ──────────────────────────────────
public static long CreatePlaylist(string name)
@@ -499,7 +530,7 @@ public static List GetPlaylistTracks(long playlistId)
using var cmd = conn.CreateCommand();
cmd.CommandText = """
SELECT t.id, t.path, t.title, t.artist, t.album, t.duration_ms,
- t.track_number, t.year, t.genre, t.folder_id, t.hash, t.last_modified, t.created_at,
+ t.track_number, t.year, t.genre, t.bpm, t.folder_id, t.hash, t.last_modified, t.created_at,
CASE WHEN f.track_id IS NOT NULL THEN 1 ELSE 0 END as is_fav
FROM tracks t
INNER JOIN playlist_tracks pt ON pt.track_id = t.id
diff --git a/Audiomatic/Services/PodcastService.cs b/Audiomatic/Services/PodcastService.cs
index b01f721..32a3679 100644
--- a/Audiomatic/Services/PodcastService.cs
+++ b/Audiomatic/Services/PodcastService.cs
@@ -76,6 +76,37 @@ public static async Task> FetchEpisodesAsync(string feedUrl
return episodes;
}
+ // -- Episode progress tracking --
+
+ private static readonly string ProgressPath =
+ Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Audiomatic", "podcast_progress.json");
+
+ /// Load saved progress (audioUrl → position in seconds).
+ public static Dictionary LoadProgress()
+ {
+ try
+ {
+ if (File.Exists(ProgressPath))
+ {
+ var json = File.ReadAllText(ProgressPath);
+ return JsonSerializer.Deserialize>(json, JsonOpts) ?? [];
+ }
+ }
+ catch { }
+ return [];
+ }
+
+ public static void SaveProgress(Dictionary progress)
+ {
+ try
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(ProgressPath)!);
+ var json = JsonSerializer.Serialize(progress, JsonOpts);
+ File.WriteAllText(ProgressPath, json);
+ }
+ catch { }
+ }
+
// -- Read/unread episode tracking --
private static readonly string ReadPath =
@@ -107,6 +138,99 @@ public static void SaveReadEpisodes(HashSet readUrls)
catch { }
}
+ // -- Episode downloads --
+
+ private static readonly string DownloadsDir =
+ Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Audiomatic", "podcasts");
+
+ ///
+ /// Deterministic local file path for a given episode audio URL.
+ ///
+ ///
+ /// Supported download extensions — formats NAudio can play natively.
+ ///
+ private static readonly HashSet SupportedDownloadExtensions =
+ new(StringComparer.OrdinalIgnoreCase) { ".mp3", ".wav", ".flac", ".aac", ".ogg", ".m4a", ".aiff" };
+
+ public static string GetDownloadPath(string audioUrl)
+ {
+ // Use a hash of the URL to avoid filesystem issues with long/special-char URLs
+ var hash = Convert.ToHexString(
+ System.Security.Cryptography.SHA256.HashData(
+ System.Text.Encoding.UTF8.GetBytes(audioUrl)))[..24];
+ // Keep original extension only if NAudio supports it, otherwise default to .mp3
+ var ext = Path.GetExtension(new Uri(audioUrl).AbsolutePath);
+ if (string.IsNullOrEmpty(ext) || !SupportedDownloadExtensions.Contains(ext))
+ ext = ".mp3";
+ return Path.Combine(DownloadsDir, hash + ext);
+ }
+
+ public static bool IsDownloaded(string audioUrl)
+ {
+ var path = GetDownloadPath(audioUrl);
+ return File.Exists(path);
+ }
+
+ public static async Task DownloadEpisodeAsync(string audioUrl,
+ IProgress? progress = null, CancellationToken ct = default)
+ {
+ Directory.CreateDirectory(DownloadsDir);
+ var destPath = GetDownloadPath(audioUrl);
+
+ // If already downloaded, skip
+ if (File.Exists(destPath)) return;
+
+ var tmpPath = destPath + ".tmp";
+
+ try
+ {
+ using var response = await Http.GetAsync(audioUrl, HttpCompletionOption.ResponseHeadersRead, ct);
+ response.EnsureSuccessStatusCode();
+
+ var totalBytes = response.Content.Headers.ContentLength ?? -1;
+ long downloaded = 0;
+
+ // Explicit using blocks so streams are closed before File.Move
+ using (var contentStream = await response.Content.ReadAsStreamAsync(ct))
+ using (var fileStream = new FileStream(tmpPath, FileMode.Create, FileAccess.Write, FileShare.None, 81920, true))
+ {
+ var buffer = new byte[81920];
+ int bytesRead;
+ while ((bytesRead = await contentStream.ReadAsync(buffer, ct)) > 0)
+ {
+ await fileStream.WriteAsync(buffer.AsMemory(0, bytesRead), ct);
+ downloaded += bytesRead;
+ if (totalBytes > 0)
+ progress?.Report((double)downloaded / totalBytes);
+ }
+ }
+ // Streams are now closed — safe to rename
+ File.Move(tmpPath, destPath, overwrite: true);
+ progress?.Report(1.0);
+ }
+ catch
+ {
+ // Clean up partial .tmp file on any failure
+ try { if (File.Exists(tmpPath)) File.Delete(tmpPath); } catch { }
+ throw;
+ }
+ }
+
+ public static void DeleteDownload(string audioUrl)
+ {
+ var path = GetDownloadPath(audioUrl);
+ try { if (File.Exists(path)) File.Delete(path); } catch { }
+ }
+
+ public static long GetDownloadsSizeBytes()
+ {
+ if (!Directory.Exists(DownloadsDir)) return 0;
+ return new DirectoryInfo(DownloadsDir)
+ .EnumerateFiles()
+ .Where(f => !f.Name.EndsWith(".tmp"))
+ .Sum(f => f.Length);
+ }
+
///
/// Load saved podcast subscriptions.
///
diff --git a/Audiomatic/Services/QueueManager.cs b/Audiomatic/Services/QueueManager.cs
index 138652f..a77fac7 100644
--- a/Audiomatic/Services/QueueManager.cs
+++ b/Audiomatic/Services/QueueManager.cs
@@ -190,6 +190,20 @@ public void ReorderQueue(List newOrder)
return track;
}
+ ///
+ /// Returns the next track that would play without advancing the index.
+ ///
+ public TrackInfo? PeekNext()
+ {
+ if (_playQueue.Count == 0) return null;
+ return _repeat switch
+ {
+ RepeatMode.One => CurrentTrack,
+ RepeatMode.All => _playQueue[(_currentIndex + 1) % _playQueue.Count],
+ _ => _currentIndex < _playQueue.Count - 1 ? _playQueue[_currentIndex + 1] : null
+ };
+ }
+
public bool HasNext()
{
if (_repeat == RepeatMode.All || _repeat == RepeatMode.One) return _playQueue.Count > 0;
diff --git a/Audiomatic/Services/SpeedControlSampleProvider.cs b/Audiomatic/Services/SpeedControlSampleProvider.cs
new file mode 100644
index 0000000..046c10e
--- /dev/null
+++ b/Audiomatic/Services/SpeedControlSampleProvider.cs
@@ -0,0 +1,96 @@
+using NAudio.Wave;
+
+namespace Audiomatic.Services;
+
+///
+/// Sample provider that changes playback speed via linear interpolation.
+/// Speed > 1 = faster (consumes more source samples per output frame).
+/// Speed < 1 = slower (consumes fewer source samples per output frame).
+/// Note: this changes both speed and pitch (like a vinyl speed change).
+///
+public sealed class SpeedControlSampleProvider : ISampleProvider
+{
+ private readonly ISampleProvider _source;
+ private readonly int _channels;
+ private float _speed = 1.0f;
+ private float[] _sourceBuffer = new float[8192];
+ private int _sourceBufferCount;
+ private double _sourcePosition;
+
+ public SpeedControlSampleProvider(ISampleProvider source)
+ {
+ _source = source;
+ _channels = source.WaveFormat.Channels;
+ }
+
+ public float Speed
+ {
+ get => _speed;
+ set => _speed = Math.Clamp(value, 0.25f, 4.0f);
+ }
+
+ public WaveFormat WaveFormat => _source.WaveFormat;
+
+ /// Clear internal buffer — call after seeking the source.
+ public void Reset()
+ {
+ _sourceBufferCount = 0;
+ _sourcePosition = 0;
+ }
+
+ public int Read(float[] buffer, int offset, int count)
+ {
+ if (_speed == 1.0f)
+ return _source.Read(buffer, offset, count);
+
+ int outputFrames = count / _channels;
+ int written = 0;
+
+ for (int i = 0; i < outputFrames; i++)
+ {
+ int neededSample = ((int)_sourcePosition + 1) * _channels;
+ while (neededSample >= _sourceBufferCount)
+ {
+ EnsureCapacity(_sourceBufferCount + 4096);
+ int read = _source.Read(_sourceBuffer, _sourceBufferCount, 4096);
+ if (read == 0)
+ return written;
+ _sourceBufferCount += read;
+ }
+
+ int srcFrame = (int)_sourcePosition;
+ float frac = (float)(_sourcePosition - srcFrame);
+
+ for (int ch = 0; ch < _channels; ch++)
+ {
+ float s0 = _sourceBuffer[srcFrame * _channels + ch];
+ float s1 = _sourceBuffer[(srcFrame + 1) * _channels + ch];
+ buffer[offset + written++] = s0 + (s1 - s0) * frac;
+ }
+
+ _sourcePosition += _speed;
+ }
+
+ // Compact: discard consumed frames
+ int consumedFrames = (int)_sourcePosition;
+ if (consumedFrames > 0)
+ {
+ int consumedSamples = consumedFrames * _channels;
+ int remaining = _sourceBufferCount - consumedSamples;
+ if (remaining > 0)
+ Array.Copy(_sourceBuffer, consumedSamples, _sourceBuffer, 0, remaining);
+ _sourceBufferCount = remaining;
+ _sourcePosition -= consumedFrames;
+ }
+
+ return written;
+ }
+
+ private void EnsureCapacity(int needed)
+ {
+ if (_sourceBuffer.Length >= needed) return;
+ var newBuf = new float[Math.Max(needed, _sourceBuffer.Length * 2)];
+ Array.Copy(_sourceBuffer, newBuf, _sourceBufferCount);
+ _sourceBuffer = newBuf;
+ }
+}
diff --git a/README.md b/README.md
index 6fd56a6..8894204 100644
--- a/README.md
+++ b/README.md
@@ -19,6 +19,10 @@ A modern, compact desktop audio player for Windows 11, built with WinUI 3 and .N
- **Playback controls**: Play/Pause, Previous, Next, timeline seeking
- **Volume control** with mute toggle and dynamic icon states
- **Shuffle** (Fisher-Yates) and **Repeat** modes (None / All / One)
+- **Gapless playback** — seamless track transitions with pre-loaded next track chain; automatic fallback for incompatible formats
+- **BPM detection** — reads BPM from file tags or analyzes audio on demand (multi-band onset detection + comb filter); displayed in track list and sortable
+- **Playback speed** — adjustable from 0.25x to 4x with 9 presets, applied to both local files and streams
+- **Sleep timer** — auto-stop playback after 15, 30, 45, 60, or 90 minutes, accessible from the settings menu
### Music Library
@@ -58,6 +62,10 @@ A modern, compact desktop audio player for Windows 11, built with WinUI 3 and .N
- **Direct playback** — play any episode directly in the built-in player
- **Read/unread tracking** — episodes automatically marked as read when fully listened
- **Manual toggle** — mark episodes as read or unread via Raycast-style context menu
+- **Episode downloads** — download episodes for offline listening (stored in `%LOCALAPPDATA%\Audiomatic\podcasts\`), with cancel and delete options
+- **Smart playback** — downloaded episodes play via NAudio with full equalizer and seeking support; non-downloaded episodes stream directly
+- **Playback resume** — episode progress saved automatically and restored on next play
+- **Unread badges** — subscription cards show the number of unread episodes
- **Subscription management** — unsubscribe from podcasts via context menu
### Equalizer
@@ -71,7 +79,7 @@ A modern, compact desktop audio player for Windows 11, built with WinUI 3 and .N
### Search & Sort
- Real-time filtering by title, artist, or album
-- Sort by Title, Artist, Album, or Duration
+- Sort by Title, Artist, Album, Duration, or BPM
- Ascending/Descending toggle

@@ -91,6 +99,8 @@ A modern, compact desktop audio player for Windows 11, built with WinUI 3 and .N
| **Queue** | View and manage the current playback queue |
| **Radio** | Play online radio streams with station management |
| **Podcasts** | Search, subscribe, browse episodes, and play podcasts |
+| **Albums** | Browse library grouped by album with artwork cards |
+| **Artists** | Browse library grouped by artist with circular artwork cards |
| **Equalizer** | 10-band graphic EQ with presets, preamp, and per-band control |
| **Visualizer** | Real-time FFT spectrum analyzer with mirror mode (via "..." menu) |
| **Media Control** | Monitor and control background media players via "..." menu |
@@ -117,7 +127,7 @@ A modern, compact desktop audio player for Windows 11, built with WinUI 3 and .N
- **Animated transitions** — Fluent slide+fade animations between views
- **Collapse cycling** with smooth animation anchored to bottom (`Ctrl+L`)
- **Always-on-Top** pin mode
-- **Backdrop options**: Acrylic, Mica, Mica Alt, None
+- **Backdrop options**: Acrylic, Custom Acrylic (tint/fallback with ColorPicker, luminosity, Base/Thin style), Mica, Mica Alt, None
- **Theme support**: System, Light, Dark
- **Custom accent colors** — 24 preset color swatches + custom hex input, applied across all themes
- **Window position** remembered across restarts
@@ -128,7 +138,7 @@ A modern, compact desktop audio player for Windows 11, built with WinUI 3 and .N
- **System tray** — minimize to tray, left-click to show/hide, right-click menu
- **System Media Transport Controls (SMTC)** — play/pause, next/previous, track info and artwork displayed in Windows media overlay
-- **Drag & Drop** — drop audio files from Explorer to play or queue them
+- **Drag & Drop** — drop audio files from Explorer to play or queue them, or drop folders to add them to the library
- **Global hotkeys**:
- `Ctrl+Alt+M` — Show/Hide window
- `Ctrl+L` — Cycle display modes (Expanded → Compact → Mini → Expanded)
@@ -168,6 +178,8 @@ All application data is stored in `%LOCALAPPDATA%\Audiomatic\`:
- `radio_stations.json` — Saved radio stations
- `podcasts.json` — Podcast subscriptions
- `podcast_read.json` — Read/unread episode tracking
+- `podcast_progress.json` — Episode playback progress for resume
+- `podcasts/` — Downloaded podcast episodes
## Building
diff --git a/installer.iss b/installer.iss
index 2f48027..2238b8b 100644
--- a/installer.iss
+++ b/installer.iss
@@ -1,6 +1,6 @@
[Setup]
AppName=Audiomatic
-AppVersion=0.0.6
+AppVersion=0.1.0
AppPublisher=OhMyCode
DefaultDirName={localappdata}\Programs\Audiomatic
DefaultGroupName=Audiomatic